16 Commits
Author SHA1 Message Date
alex 8eecce00a6 Release v0.0.6: resilient BOM metadata parsing.
Read C07_WeatherMetadata via innerText, accept observation prefixes (a minute,
N minutes, an hour, N hours) and forecast N hours ago. Version 0.0.6.
2026-05-07 05:49:51 +10:00
alex 28e8cb4fd1 Bump version to 0.0.5 for release (v0.0.4 already exists on GitHub). 2026-05-07 05:23:49 +10:00
alex a54061f757 Release v0.0.4: BOM spatial map scraping and radar cache reliability.
Align selectors and workflow with the step-through scrubber, share scrub display
timestamp parsing via IScrubDisplayTimestampParser, fix pause post-click label
verification, and stop cancelling background cache updates when the HTTP request
ends. Version bumped to 0.0.4.
2026-05-07 05:23:18 +10:00
alex 0b6d266e79 Replace large image with smaller icon in README 2025-12-24 02:15:35 +10:00
alex 1a257931e8 Refactor SPA: extract CSS/JS to modular files, add static file serving, fix timestamp parsing 2025-12-24 00:11:09 +10:00
alex 0a29c2d0c1 Fix frame timestamp parsing: detect AEST/AEDT and use parsed date directly 2025-12-24 00:09:50 +10:00
alex ffd3636d1c Remove minutes ago parsing, use UTC timestamps directly
- Refactor TimeParsingService to parse time strings to UTC only
- Remove minutesAgo from RadarFrame and FrameMetadata models
- Update CaptureFramesStep to extract UTC timestamps directly
- Update CacheService to store/load ObservationTime instead of MinutesAgo
- Update BomRadarService to use AbsoluteObservationTime directly
- Remove all backward compatibility code for old cache folders
- Update documentation and test SPA to calculate minutes ago on client
- Fix step registration to register concrete types for DI resolution
- Fix hosted service registration to use reflection correctly
2025-12-23 21:52:42 +10:00
alex b30d76ddec Refactor configuration system and remove OpenAPI
- Remove OpenAPI/Swagger support and all related code
- Standardize configuration: remove hardcoded defaults, all defaults from appsettings.json
- Configuration values throw exceptions if missing (except Debug:Enabled defaults to false)
- Refactor CacheManagementService: extract ProcessLocationsAsync helper, remove duplicate code
- Combine UpdateStaggerSeconds and LocationStaggerSeconds into single LocationStaggerSeconds config
- Move config reads to constructors for better performance
- Update README and docker-compose.yml to reflect configuration changes
2025-12-23 21:03:21 +10:00
alex 12dd0eedad Refactor scraping system to workflow-based architecture
Major architectural changes:
- Replace monolithic ScrapingService with workflow-based system
- Extract scraping logic into discrete, testable steps
- Add configuration-driven selectors, JavaScript templates, and text patterns
- Implement step registry and workflow factory for extensibility
- Add PageState tracking and step dependencies
- Make IWorkflow generic to support different response types

New components:
- Scraping steps organized by category (Navigation, Search, Map, Metadata, Capture)
- BaseScrapingStep base class with common dependencies
- ScrapingContext for shared state between steps
- RadarScrapingWorkflow orchestrating step execution
- SelectorService for configurable element selection
- Configuration models (SelectorConfig, TextPatternsConfig, JavaScriptTemplatesConfig)

Performance and monitoring:
- Add step-level timing metrics with historical tracking
- Log step durations and compare to averages
- Warn on slow steps/workflows (50%+ slower than average)
- Integrate metrics with CacheService

Configuration improvements:
- Externalize all selectors to appsettings.json
- Externalize JavaScript templates to appsettings.json
- Externalize text patterns to appsettings.json
- Support Docker configuration via mounted appsettings.json
- Remove hardcoded waits, use configurable timeouts

Bug fixes:
- Fix DebugService collection modification exception
- Fix ResetToFirstFrameStep click interception with JavaScript fallback
- Remove minutes ago fallback, use timestamp parsing only
- Update timestamp parsing for new BOM website format

Documentation:
- Update README with new architecture details
- Add configuration section with Docker guidance
- Add development guidelines for extending scraping system
- Add performance monitoring documentation
2025-12-18 00:54:57 +10:00
alex 5e31ed33c6 Fix TimeRangeError builder and update error response docs
- Fix TimeRangeError to properly extract properties from anonymous objects
- Add suggestions field to timeseries error response examples in README
- Enhance demo app to use suggested ranges from error responses
2025-12-17 01:24:39 +10:00
alex 92c241b26e Improve time series endpoint error handling and remove arbitrary limits
- Make time series endpoint consistent with main radar endpoint (triggers background update, provides cache status)
- Add validation for large time ranges with meaningful error messages
- Make time range limit configurable via TimeSeries:MaxTimeRangeHours (defaults to CacheRetentionHours)
- Remove arbitrary max limits from demo app settings (frame interval, refresh interval)
- Update documentation to reflect configurable limits and improved error handling
2025-12-17 01:24:39 +10:00
alex 0ace8f4bc5 Added logo
Added logo
2025-12-16 20:39:06 +10:00
alex ba8694947e Fix: Ensure consistent time representation across endpoints
- Add AbsoluteObservationTime calculation to normal endpoint response
- Ensures both normal and time series endpoints return consistent time data
- Frontend already supports absoluteObservationTime field
2025-12-16 19:26:27 +10:00
alex d60706f625 Fix: Improve cache stability and configuration consistency
Active Folder Exclusion Fixes:
- Exclude active cache folders in GetCachedFrameAsync, GetCachedFramesAsync, and GetAllCacheFoldersAsync
- Prevents TaskCanceledException when reading metadata from folders being written to
- Matches pattern used in GetCachedRadarAsync for consistency

Cache Configuration Fixes:
- Standardize default CacheExpirationMinutes to 12.5 (matches appsettings.json)
- Add cache configuration to ScrapingService (removes hardcoded values)
- Fix check interval calculation bug for intervals that don't divide 60 evenly
- Fix NextUpdateTime calculation to account for background check intervals
- Add configuration validation to all service constructors

All fixes verified - service running stably with 0 exceptions in recent logs
2025-12-16 02:33:51 +10:00
alex ee5bffde88 Fix: Exclude active cache folders in GetCachedFrameAsync to prevent reading incomplete data
- Add active cache folder exclusion to GetCachedFrameAsync
- Prevents TaskCanceledException when reading metadata from folders being written to
- Matches pattern used in GetCachedRadarAsync for consistency
2025-12-16 01:57:02 +10:00
alex e5aee5be82 Add Xvfb monitoring and restart functionality
- Move startup script to scripts/docker-start.sh for better organization
- Add automatic monitoring and restart of Xvfb if it crashes
- Improve error handling and cleanup on container shutdown
- Prevents cache update failures when Xvfb process dies
2025-12-10 10:46:10 +10:00
87 changed files with 7635 additions and 3205 deletions
+1 -1
View File
@@ -4,10 +4,10 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.6</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" /> <PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
</ItemGroup> </ItemGroup>
+33 -8
View File
@@ -35,14 +35,21 @@ public class CacheController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
} }
var result = await _bomRadarService.GetCacheRangeAsync(suburb, state, cancellationToken); var result = await _bomRadarService.GetCacheRangeAsync(suburb, state, cancellationToken);
if (result.TotalCacheFolders == 0) if (result.TotalCacheFolders == 0)
{ {
return NotFound(new { error = "No cached data found for this location." }); var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state);
errorResponse.Message = "No cached data found for this location.";
errorResponse.Suggestions = new Dictionary<string, object>
{
{ "action", "refresh_cache" },
{ "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" }
};
return NotFound(errorResponse);
} }
return Ok(result); return Ok(result);
@@ -50,7 +57,11 @@ public class CacheController : ControllerBase
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting cache range for suburb: {Suburb}, state: {State}", suburb, state); _logger.LogError(ex, "Error getting cache range for suburb: {Suburb}, state: {State}", suburb, state);
return StatusCode(500, new { error = "An error occurred while getting cache range", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.InternalError(
"An error occurred while getting cache range",
ex);
errorResponse.Details!["location"] = new { suburb, state };
return StatusCode(500, errorResponse);
} }
} }
@@ -65,7 +76,7 @@ public class CacheController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
} }
var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken); var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken);
@@ -74,7 +85,12 @@ public class CacheController : ControllerBase
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error triggering cache update for suburb: {Suburb}, state: {State}", suburb, state); _logger.LogError(ex, "Error triggering cache update for suburb: {Suburb}, state: {State}", suburb, state);
return StatusCode(500, new { error = "An error occurred while triggering cache update", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.CacheUpdateFailed(
suburb,
state,
"Failed to trigger cache update",
ex);
return StatusCode(500, errorResponse);
} }
} }
@@ -89,7 +105,7 @@ public class CacheController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
} }
var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken); var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken);
@@ -100,13 +116,22 @@ public class CacheController : ControllerBase
} }
else else
{ {
return NotFound(new { error = $"No cached data found for {suburb}, {state}" }); var errorResponse = ApiErrorResponseBuilder.NotFound(
"Cache",
$"{suburb}, {state}",
"No cached data exists for this location.");
errorResponse.Details!["location"] = new { suburb, state };
return NotFound(errorResponse);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state); _logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state);
return StatusCode(500, new { error = "An error occurred while deleting cached location", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.InternalError(
"An error occurred while deleting cached location",
ex);
errorResponse.Details!["location"] = new { suburb, state };
return StatusCode(500, errorResponse);
} }
} }
} }
+214 -27
View File
@@ -37,7 +37,7 @@ public class RadarController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
} }
// Check if cache needs updating and trigger if needed (non-blocking) // Check if cache needs updating and trigger if needed (non-blocking)
@@ -60,8 +60,19 @@ public class RadarController : ControllerBase
{ {
// Get cache status to include in 404 response // Get cache status to include in 404 response
// Use cache service directly since this is cache management, not radar-specific // Use cache service directly since this is cache management, not radar-specific
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5); var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5); if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var cacheStatus = await _cacheService.GetCacheStatusAsync( var cacheStatus = await _cacheService.GetCacheStatusAsync(
suburb, suburb,
state, state,
@@ -70,17 +81,37 @@ public class RadarController : ControllerBase
cacheManagementCheckIntervalMinutes, cacheManagementCheckIntervalMinutes,
cancellationToken); cancellationToken);
return NotFound(new { // Determine if update was triggered (check if it's already in progress or was just triggered)
error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.", var updateTriggered = cacheStatus.UpdateTriggered ||
retryAfter = 30, // seconds (cacheStatus.Message?.Contains("in progress") ?? false) ||
refreshEndpoint = $"/api/cache/{suburb}/{state}/refresh", (!cacheStatus.CacheExists && !cacheStatus.CacheIsValid);
updateTriggered = cacheStatus.UpdateTriggered,
cacheExists = cacheStatus.CacheExists, var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state, cacheStatus, updateTriggered);
cacheIsValid = cacheStatus.CacheIsValid,
cacheExpiresAt = cacheStatus.CacheExpiresAt, // Enhance message based on cache status
nextUpdateTime = cacheStatus.NextUpdateTime, if (cacheStatus.UpdateFailed)
message = cacheStatus.Message {
}); errorResponse.Message = $"No cached data found. Previous update attempt failed: {cacheStatus.Error ?? "Unknown error"}. Retrying...";
errorResponse.Details!["previousUpdateFailed"] = true;
if (cacheStatus.Error != null)
{
errorResponse.Details!["previousError"] = cacheStatus.Error;
}
if (cacheStatus.ErrorCode != null)
{
errorResponse.Details!["previousErrorCode"] = cacheStatus.ErrorCode;
}
}
else if (cacheStatus.Message?.Contains("in progress") ?? false)
{
errorResponse.Message = "No cached data found. Cache update is currently in progress. Please retry shortly.";
}
else if (!cacheStatus.CacheExists)
{
errorResponse.Message = "No cached data found for this location (fresh start). Cache update has been triggered in background. Please retry in a few moments.";
}
return NotFound(errorResponse);
} }
// Generate URLs for frames if not already set // Generate URLs for frames if not already set
@@ -97,7 +128,11 @@ public class RadarController : ControllerBase
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting cached radar screenshots for suburb: {Suburb}, state: {State}", suburb, state); _logger.LogError(ex, "Error getting cached radar screenshots for suburb: {Suburb}, state: {State}", suburb, state);
return StatusCode(500, new { error = "An error occurred while getting the radar screenshots", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.InternalError(
"An error occurred while getting the radar screenshots",
ex);
errorResponse.Details!["location"] = new { suburb, state };
return StatusCode(500, errorResponse);
} }
} }
@@ -112,7 +147,15 @@ public class RadarController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
}
// Validate frame index
if (frameIndex < 0 || frameIndex > 6)
{
return BadRequest(ApiErrorResponseBuilder.ValidationError(
$"Frame index must be between 0 and 6, got {frameIndex}",
"frameIndex"));
} }
RadarFrame? frame; RadarFrame? frame;
@@ -130,7 +173,17 @@ public class RadarController : ControllerBase
if (frame == null || !System.IO.File.Exists(frame.ImagePath)) if (frame == null || !System.IO.File.Exists(frame.ImagePath))
{ {
return NotFound(new { error = $"Frame {frameIndex} not found for {suburb}, {state}" }); var errorResponse = ApiErrorResponseBuilder.NotFound(
"Frame",
$"Frame {frameIndex} for {suburb}, {state}",
"The frame may not exist yet. Try refreshing the cache or checking if cache update is in progress.");
errorResponse.Details!["frameIndex"] = frameIndex;
errorResponse.Details!["location"] = new { suburb, state };
if (!string.IsNullOrEmpty(cacheFolder))
{
errorResponse.Details["cacheFolder"] = cacheFolder;
}
return NotFound(errorResponse);
} }
var imageBytes = await System.IO.File.ReadAllBytesAsync(frame.ImagePath, cancellationToken); var imageBytes = await System.IO.File.ReadAllBytesAsync(frame.ImagePath, cancellationToken);
@@ -139,7 +192,12 @@ public class RadarController : ControllerBase
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting frame {FrameIndex} for suburb: {Suburb}, state: {State}", frameIndex, suburb, state); _logger.LogError(ex, "Error getting frame {FrameIndex} for suburb: {Suburb}, state: {State}", frameIndex, suburb, state);
return StatusCode(500, new { error = "An error occurred while getting the frame", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.InternalError(
"An error occurred while getting the frame",
ex);
errorResponse.Details!["frameIndex"] = frameIndex;
errorResponse.Details!["location"] = new { suburb, state };
return StatusCode(500, errorResponse);
} }
} }
@@ -154,14 +212,24 @@ public class RadarController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
} }
var result = await _bomRadarService.GetLastUpdatedInfoAsync(suburb, state, cancellationToken); var result = await _bomRadarService.GetLastUpdatedInfoAsync(suburb, state, cancellationToken);
if (result == null) if (result == null)
{ {
return NotFound(new { error = "No cached data found. Use POST /api/cache/{suburb}/{state}/refresh to trigger cache update." }); var errorResponse = ApiErrorResponseBuilder.NotFound(
"Metadata",
$"{suburb}, {state}",
"Use POST /api/cache/{suburb}/{state}/refresh to trigger cache update.");
errorResponse.Details!["location"] = new { suburb, state };
errorResponse.Suggestions = new Dictionary<string, object>
{
{ "action", "refresh_cache" },
{ "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" }
};
return NotFound(errorResponse);
} }
return Ok(result); return Ok(result);
@@ -169,7 +237,11 @@ public class RadarController : ControllerBase
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting metadata for suburb: {Suburb}, state: {State}", suburb, state); _logger.LogError(ex, "Error getting metadata for suburb: {Suburb}, state: {State}", suburb, state);
return StatusCode(500, new { error = "An error occurred while getting metadata", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.InternalError(
"An error occurred while getting metadata",
ex);
errorResponse.Details!["location"] = new { suburb, state };
return StatusCode(500, errorResponse);
} }
} }
@@ -190,7 +262,7 @@ public class RadarController : ControllerBase
var validationError = ValidationHelper.ValidateLocation(suburb, state); var validationError = ValidationHelper.ValidateLocation(suburb, state);
if (validationError != null) if (validationError != null)
{ {
return BadRequest(new { error = validationError }); return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
} }
DateTime? start = null; DateTime? start = null;
@@ -200,7 +272,9 @@ public class RadarController : ControllerBase
{ {
if (!DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedStart)) if (!DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedStart))
{ {
return BadRequest(new { error = "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)" }); return BadRequest(ApiErrorResponseBuilder.ValidationError(
"Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)",
"startTime"));
} }
start = parsedStart.ToUniversalTime(); start = parsedStart.ToUniversalTime();
} }
@@ -209,21 +283,129 @@ public class RadarController : ControllerBase
{ {
if (!DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedEnd)) if (!DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedEnd))
{ {
return BadRequest(new { error = "Invalid endTime format. Use ISO 8601 format (e.g., 2025-12-07T12:00:00Z)" }); return BadRequest(ApiErrorResponseBuilder.ValidationError(
"Invalid endTime format. Use ISO 8601 format (e.g., 2025-12-07T12:00:00Z)",
"endTime"));
} }
end = parsedEnd.ToUniversalTime(); end = parsedEnd.ToUniversalTime();
} }
if (start.HasValue && end.HasValue && start.Value > end.Value) if (start.HasValue && end.HasValue && start.Value > end.Value)
{ {
return BadRequest(new { error = "startTime must be before or equal to endTime" }); return BadRequest(ApiErrorResponseBuilder.ValidationError(
"startTime must be before or equal to endTime",
"timeRange"));
} }
// Validate time range size to prevent excessive data loading
// Default: base limit on cache retention, but allow override via config
var cacheRetentionHoursConfig = _configuration.GetValue<int?>("CacheRetentionHours");
if (!cacheRetentionHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
}
var cacheRetentionHours = cacheRetentionHoursConfig.Value;
// TimeSeries:MaxTimeRangeHours is optional (nullable)
var configuredMaxHours = _configuration.GetValue<int?>("TimeSeries:MaxTimeRangeHours");
// Use configured value if set, otherwise use cache retention (with minimum of 24 hours)
var maxTimeRangeHours = configuredMaxHours ?? Math.Max(cacheRetentionHours, 24);
if (start.HasValue && end.HasValue)
{
var timeRange = end.Value - start.Value;
if (timeRange.TotalHours > maxTimeRangeHours)
{
var reason = configuredMaxHours.HasValue
? $"configured limit: {configuredMaxHours} hours"
: $"cache retention: {cacheRetentionHours} hours";
return BadRequest(ApiErrorResponseBuilder.TimeRangeError(
$"Time range exceeds maximum allowed duration of {maxTimeRangeHours} hours (based on {reason}). Please specify a smaller range.",
null,
new { start = start.Value, end = end.Value, requestedHours = timeRange.TotalHours }));
}
}
// Check if location has any cache at all (to distinguish from "no data in range")
var cacheRange = await _bomRadarService.GetCacheRangeAsync(suburb, state, cancellationToken);
var locationHasCache = cacheRange.TotalCacheFolders > 0;
// If no cache exists for this location, trigger background update and return detailed 404
if (!locationHasCache)
{
// Trigger background cache update (non-blocking)
_ = Task.Run(async () =>
{
try
{
await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Background cache update failed for {Suburb}, {State}", suburb, state);
}
});
// Get cache status to include in 404 response
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var cacheStatus = await _cacheService.GetCacheStatusAsync(
suburb,
state,
CachedDataType.Radar,
cacheExpirationMinutes,
cacheManagementCheckIntervalMinutes,
cancellationToken);
var updateTriggered = cacheStatus.UpdateTriggered ||
(cacheStatus.Message?.Contains("in progress") ?? false) ||
(!cacheStatus.CacheExists && !cacheStatus.CacheIsValid);
var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state, cacheStatus, updateTriggered);
errorResponse.Message = "No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments.";
errorResponse.Details!["requestedTimeRange"] = new { start, end };
return NotFound(errorResponse);
}
// Location has cache, check if there's data in the requested time range
var result = await _bomRadarService.GetRadarTimeSeriesAsync(suburb, state, start, end, cancellationToken); var result = await _bomRadarService.GetRadarTimeSeriesAsync(suburb, state, start, end, cancellationToken);
if (result.CacheFolders.Count == 0) if (result.CacheFolders.Count == 0)
{ {
return NotFound(new { error = "No historical data found for the specified time range." }); // Location has cache, but no data in the requested time range
var oldestCache = cacheRange.OldestCache?.CacheTimestamp;
var newestCache = cacheRange.NewestCache?.CacheTimestamp;
var availableRange = new {
oldest = oldestCache,
newest = newestCache,
totalCacheFolders = cacheRange.TotalCacheFolders,
timeSpanMinutes = cacheRange.TimeSpanMinutes
};
var requestedRange = new {
start = start,
end = end
};
return NotFound(ApiErrorResponseBuilder.TimeRangeError(
"No historical data found for the specified time range.",
availableRange,
requestedRange));
} }
return Ok(result); return Ok(result);
@@ -231,7 +413,12 @@ public class RadarController : ControllerBase
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting radar time series for suburb: {Suburb}, state: {State}", suburb, state); _logger.LogError(ex, "Error getting radar time series for suburb: {Suburb}, state: {State}", suburb, state);
return StatusCode(500, new { error = "An error occurred while getting radar time series", message = ex.Message }); var errorResponse = ApiErrorResponseBuilder.InternalError(
"An error occurred while getting radar time series",
ex);
errorResponse.Details!["location"] = new { suburb, state };
errorResponse.Details!["requestedTimeRange"] = new { startTime, endTime };
return StatusCode(500, errorResponse);
} }
} }
} }
+7 -7
View File
@@ -24,7 +24,7 @@ RUN apt-get update && \
chmod +x dotnet-install.sh && \ chmod +x dotnet-install.sh && \
./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet && \ ./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet && \
rm dotnet-install.sh && \ rm dotnet-install.sh && \
apt-get install -y xvfb x11vnc fluxbox && \ apt-get install -y xvfb x11vnc fluxbox curl && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -34,12 +34,9 @@ COPY --from=publish /app/publish .
# Create cache directory for screenshots # Create cache directory for screenshots
RUN mkdir -p /app/cache && chmod 777 /app/cache RUN mkdir -p /app/cache && chmod 777 /app/cache
# Create startup script to run Xvfb and then the app # Copy startup script
RUN echo '#!/bin/bash\n\ COPY scripts/docker-start.sh /app/start.sh
Xvfb :99 -screen 0 1920x1080x24 > /dev/null 2>&1 &\n\ RUN chmod +x /app/start.sh
export DISPLAY=:99\n\
exec dotnet BomLocalService.dll' > /app/start.sh && \
chmod +x /app/start.sh
# OCI labels for GitHub Container Registry metadata # OCI labels for GitHub Container Registry metadata
LABEL org.opencontainers.image.source="https://github.com/alexhopeoconnor/bom-local-service" LABEL org.opencontainers.image.source="https://github.com/alexhopeoconnor/bom-local-service"
@@ -48,4 +45,7 @@ LABEL org.opencontainers.image.licenses=MIT
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl --fail http://localhost:8080/api/health || exit 1
ENTRYPOINT ["/app/start.sh"] ENTRYPOINT ["/app/start.sh"]
+259
View File
@@ -0,0 +1,259 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Services.Scraping;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace BomLocalService.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures CORS with settings from configuration.
/// Configuration values come from appsettings.json (defaults) and can be overridden via environment variables.
/// Environment variables use double underscore for nested keys (e.g., CORS__ALLOWEDORIGINS).
/// </summary>
public static IServiceCollection AddCorsConfiguration(this IServiceCollection services, IConfiguration configuration)
{
var corsOrigins = configuration.GetValue<string>("Cors:AllowedOrigins")
?? throw new InvalidOperationException("Cors:AllowedOrigins configuration is required. Set it in appsettings.json or via CORS__ALLOWEDORIGINS environment variable.");
var corsMethods = configuration.GetValue<string>("Cors:AllowedMethods")
?? throw new InvalidOperationException("Cors:AllowedMethods configuration is required. Set it in appsettings.json or via CORS__ALLOWEDMETHODS environment variable.");
var corsHeaders = configuration.GetValue<string>("Cors:AllowedHeaders")
?? throw new InvalidOperationException("Cors:AllowedHeaders configuration is required. Set it in appsettings.json or via CORS__ALLOWEDHEADERS environment variable.");
// For bool, check if the key exists in configuration (GetValue<bool> returns false if not found, which is ambiguous)
var corsAllowCredentialsKey = configuration["Cors:AllowCredentials"];
if (corsAllowCredentialsKey == null)
{
throw new InvalidOperationException("Cors:AllowCredentials configuration is required. Set it in appsettings.json or via CORS__ALLOWCREDENTIALS environment variable.");
}
var corsAllowCredentials = configuration.GetValue<bool>("Cors:AllowCredentials");
services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (corsOrigins == "*")
{
policy.AllowAnyOrigin();
}
else
{
// Split comma-separated origins
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithOrigins(origins);
}
// Split comma-separated methods
var methods = corsMethods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithMethods(methods);
// Split comma-separated headers or allow all
if (corsHeaders == "*")
{
policy.AllowAnyHeader();
}
else
{
var headers = corsHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithHeaders(headers);
}
if (corsAllowCredentials)
{
policy.AllowCredentials();
}
});
});
return services;
}
/// <summary>
/// Scans the assembly for services implementing registration interfaces and registers them automatically.
/// Services are registered as their concrete interface (that inherits from registration interface).
/// Properly handles generic interfaces by using concrete interfaces that hide the generics.
/// </summary>
public static IServiceCollection ScanAndRegisterServices(this IServiceCollection services, Assembly assembly)
{
var registeredTypes = new HashSet<Type>(); // Track registered types to avoid duplicates
// Get all types from the assembly with improved filtering
var types = GetRegisterableTypes(assembly);
// Register Singleton services
RegisterServicesByLifetime<ISingletonService>(
services,
types,
registeredTypes);
// Register Scoped services
RegisterServicesByLifetime<IScopedService>(
services,
types,
registeredTypes);
// Register Transient services
RegisterServicesByLifetime<ITransientService>(
services,
types,
registeredTypes);
// Register Hosted Services
RegisterHostedServices(services, types, registeredTypes);
return services;
}
/// <summary>
/// Gets all types that should be considered for registration.
/// Filters out abstract classes, interfaces, generic type definitions, nested private types,
/// compiler-generated types, and types from excluded namespaces.
/// </summary>
private static List<Type> GetRegisterableTypes(Assembly assembly)
{
return assembly.GetTypes()
.Where(t =>
// Must be a class (not interface, struct, enum, etc.)
t.IsClass
// Must be concrete (not abstract)
&& !t.IsAbstract
// Must not be a generic type definition (but closed generics are OK)
&& !t.IsGenericTypeDefinition
// Must be public (or nested public in a public type)
&& (t.IsPublic || (t.IsNestedPublic && t.DeclaringType?.IsPublic == true))
// Must not be compiler-generated (e.g., async state machines, iterator classes)
&& !t.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false)
// Must not be a nested private/internal type
&& !(t.IsNested && !t.IsNestedPublic)
// Exclude test namespaces if any (optional - adjust as needed)
&& !t.Namespace?.StartsWith("BomLocalService.Tests", StringComparison.Ordinal) == true
)
.ToList();
}
private static void RegisterServicesByLifetime<TRegistrationInterface>(
IServiceCollection services,
List<Type> types,
HashSet<Type> registeredTypes)
where TRegistrationInterface : class
{
foreach (var implementationType in types)
{
// Skip if already registered
if (registeredTypes.Contains(implementationType))
continue;
// Get all interfaces implemented by this type
var allInterfaces = implementationType.GetInterfaces().ToList();
// Find interfaces that inherit from TRegistrationInterface (but not the registration interface itself)
var registrationInterfaces = allInterfaces
.Where(i => typeof(TRegistrationInterface).IsAssignableFrom(i)
&& i != typeof(TRegistrationInterface)
&& !IsGenericTypeDefinition(i))
.ToList();
if (registrationInterfaces.Count == 0)
continue;
// For each registration interface, find the most specific concrete interface
// Priority: concrete interfaces (like IRadarScrapingWorkflow) over generic interfaces (like IWorkflow<T>)
foreach (var registrationInterface in registrationInterfaces)
{
// Skip if this is a generic interface definition (we prefer concrete interfaces)
if (registrationInterface.IsGenericTypeDefinition)
continue;
// Check if this interface is already registered to avoid duplicates
var alreadyRegistered = services.Any(s =>
s.ServiceType == registrationInterface &&
s.ImplementationType == implementationType);
if (alreadyRegistered)
continue;
// Determine lifetime based on registration interface
var lifetime = GetLifetime<TRegistrationInterface>();
// Register the service by interface
if (lifetime == ServiceLifetime.Singleton)
{
services.AddSingleton(registrationInterface, implementationType);
// Also register as concrete type for direct resolution (e.g., for ScrapingStepRegistry)
services.AddSingleton(implementationType);
}
else if (lifetime == ServiceLifetime.Scoped)
{
services.AddScoped(registrationInterface, implementationType);
services.AddScoped(implementationType);
}
else if (lifetime == ServiceLifetime.Transient)
{
services.AddTransient(registrationInterface, implementationType);
services.AddTransient(implementationType);
}
registeredTypes.Add(implementationType);
}
}
}
private static bool IsGenericTypeDefinition(Type type)
{
return type.IsGenericTypeDefinition ||
(type.IsGenericType && type.GetGenericTypeDefinition() == type);
}
private static ServiceLifetime GetLifetime<TRegistrationInterface>()
where TRegistrationInterface : class
{
if (typeof(ISingletonService).IsAssignableFrom(typeof(TRegistrationInterface)))
return ServiceLifetime.Singleton;
if (typeof(IScopedService).IsAssignableFrom(typeof(TRegistrationInterface)))
return ServiceLifetime.Scoped;
if (typeof(ITransientService).IsAssignableFrom(typeof(TRegistrationInterface)))
return ServiceLifetime.Transient;
throw new InvalidOperationException($"Unknown registration interface: {typeof(TRegistrationInterface).Name}");
}
private static void RegisterHostedServices(
IServiceCollection services,
List<Type> types,
HashSet<Type> registeredTypes)
{
foreach (var type in types)
{
// Skip if already registered
if (registeredTypes.Contains(type))
continue;
// Check if it's a BackgroundService that implements IHostedServiceRegistration
if (typeof(Microsoft.Extensions.Hosting.BackgroundService).IsAssignableFrom(type) &&
typeof(IHostedServiceRegistration).IsAssignableFrom(type))
{
// Check if already registered
var alreadyRegistered = services.Any(s =>
s.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) &&
s.ImplementationType == type);
if (!alreadyRegistered)
{
// Use AddHostedService via reflection - this is the recommended way
// It properly handles lifecycle management for hosted services
// AddHostedService<T> is generic, so we need to call it via reflection
var addHostedServiceMethod = typeof(Microsoft.Extensions.DependencyInjection.ServiceCollectionHostedServiceExtensions)
.GetMethod(nameof(Microsoft.Extensions.DependencyInjection.ServiceCollectionHostedServiceExtensions.AddHostedService),
new[] { typeof(IServiceCollection) })!
.MakeGenericMethod(type);
addHostedServiceMethod.Invoke(null, new object[] { services });
registeredTypes.Add(type);
}
}
}
}
}
+340
View File
@@ -0,0 +1,340 @@
namespace BomLocalService.Models;
/// <summary>
/// Standardized error response model for API endpoints.
/// Provides consistent error structure with error codes, types, and detailed information.
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// Machine-readable error code for programmatic handling.
/// Examples: "CACHE_NOT_FOUND", "VALIDATION_ERROR", "INTERNAL_ERROR", "CACHE_UPDATE_FAILED"
/// </summary>
public string ErrorCode { get; set; } = string.Empty;
/// <summary>
/// Human-readable error message describing what went wrong.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Error type/category for grouping similar errors.
/// Examples: "CacheError", "ValidationError", "ServiceError", "NotFoundError"
/// </summary>
public string ErrorType { get; set; } = string.Empty;
/// <summary>
/// Additional context about the error (e.g., field name for validation errors).
/// </summary>
public Dictionary<string, object>? Details { get; set; }
/// <summary>
/// Suggested action for the client (e.g., "retry_after_seconds", "refresh_cache").
/// </summary>
public Dictionary<string, object>? Suggestions { get; set; }
/// <summary>
/// Timestamp when the error occurred (UTC).
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Helper class for creating standardized error responses.
/// </summary>
public static class ApiErrorResponseBuilder
{
/// <summary>
/// Creates an error response for when cache is not found (fresh start scenario).
/// </summary>
public static ApiErrorResponse CacheNotFound(
string suburb,
string state,
CacheUpdateStatus? cacheStatus = null,
bool updateTriggered = false)
{
var response = new ApiErrorResponse
{
ErrorCode = "CACHE_NOT_FOUND",
ErrorType = "CacheError",
Message = "No cached data found for this location. Cache update has been triggered in background.",
Details = new Dictionary<string, object>
{
{ "location", new { suburb, state } },
{ "cacheExists", cacheStatus?.CacheExists ?? false },
{ "cacheIsValid", cacheStatus?.CacheIsValid ?? false },
{ "updateTriggered", updateTriggered || (cacheStatus?.UpdateTriggered ?? false) }
},
Suggestions = new Dictionary<string, object>
{
{ "action", "retry_after_seconds" },
{ "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" },
{ "statusEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/range" }
}
};
// Determine meaningful retryAfter based on cache status
int retryAfter;
bool isUpdateInProgress = cacheStatus?.Message?.Contains("in progress") ?? false;
if (cacheStatus != null)
{
if (cacheStatus.CacheExpiresAt.HasValue)
{
response.Details!["cacheExpiresAt"] = cacheStatus.CacheExpiresAt.Value;
}
if (cacheStatus.NextUpdateTime.HasValue)
{
response.Details!["nextUpdateTime"] = cacheStatus.NextUpdateTime.Value;
// Calculate retryAfter from NextUpdateTime if available
var secondsUntilUpdate = (int)(cacheStatus.NextUpdateTime.Value - DateTime.UtcNow).TotalSeconds;
if (isUpdateInProgress)
{
// Update is in progress - retry when it completes (typically ~2 minutes)
// Use NextUpdateTime if it's reasonable (1-3 minutes), otherwise default to 2 minutes
retryAfter = secondsUntilUpdate > 0 && secondsUntilUpdate <= 180
? secondsUntilUpdate
: 120; // Default to 2 minutes for in-progress updates
}
else if (updateTriggered || cacheStatus.UpdateTriggered)
{
// Update was just triggered - allow time for it to complete (60-90 seconds)
// Use NextUpdateTime if reasonable, otherwise default to 90 seconds
retryAfter = secondsUntilUpdate > 0 && secondsUntilUpdate <= 120
? secondsUntilUpdate
: 90; // Default to 90 seconds for newly triggered updates
}
else
{
// No update triggered yet - suggest waiting for background service check
// Cap at reasonable maximum (5 minutes)
retryAfter = secondsUntilUpdate > 0 && secondsUntilUpdate <= 300
? secondsUntilUpdate
: 60; // Default to 60 seconds if NextUpdateTime is too far or in past
}
}
else
{
// No NextUpdateTime available - use defaults based on scenario
if (isUpdateInProgress)
{
retryAfter = 120; // 2 minutes for in-progress updates
}
else if (updateTriggered || cacheStatus.UpdateTriggered)
{
retryAfter = 90; // 90 seconds for newly triggered updates
}
else
{
retryAfter = 60; // 60 seconds default
}
}
response.Details!["statusMessage"] = cacheStatus.Message ?? "Unknown status";
// Add update failure information if available
if (cacheStatus.UpdateFailed)
{
response.Details!["previousUpdateFailed"] = true;
if (cacheStatus.Error != null)
{
response.Details!["previousError"] = cacheStatus.Error;
}
if (cacheStatus.ErrorCode != null)
{
response.Details!["previousErrorCode"] = cacheStatus.ErrorCode;
}
// Suggest manual refresh if previous update failed
response.Suggestions!["action"] = "manual_refresh_recommended";
}
}
else
{
// No cache status - default to 90 seconds for fresh start
retryAfter = 90;
}
// Ensure retryAfter is within reasonable bounds (30 seconds to 5 minutes)
retryAfter = Math.Max(30, Math.Min(retryAfter, 300));
response.Suggestions!["retryAfter"] = retryAfter;
return response;
}
/// <summary>
/// Creates an error response for validation errors.
/// </summary>
public static ApiErrorResponse ValidationError(string message, string? field = null)
{
var response = new ApiErrorResponse
{
ErrorCode = "VALIDATION_ERROR",
ErrorType = "ValidationError",
Message = message
};
if (!string.IsNullOrEmpty(field))
{
response.Details = new Dictionary<string, object> { { "field", field } };
}
return response;
}
/// <summary>
/// Creates an error response for when cache update fails.
/// </summary>
public static ApiErrorResponse CacheUpdateFailed(string suburb, string state, string reason, Exception? exception = null)
{
var response = new ApiErrorResponse
{
ErrorCode = "CACHE_UPDATE_FAILED",
ErrorType = "ServiceError",
Message = $"Failed to update cache for {suburb}, {state}: {reason}",
Details = new Dictionary<string, object>
{
{ "location", new { suburb, state } },
{ "reason", reason }
},
Suggestions = new Dictionary<string, object>
{
{ "action", "retry_after_seconds" },
{ "retryAfter", 60 }, // Wait 1 minute before retrying failed update
{ "refreshEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/refresh" },
{ "statusEndpoint", $"/api/cache/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/range" }
}
};
if (exception != null)
{
response.Details["exceptionType"] = exception.GetType().Name;
response.Details["exceptionMessage"] = exception.Message;
// Adjust retry suggestion based on exception type
if (exception is TimeoutException || exception.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase))
{
response.Suggestions!["retryAfter"] = 30; // Shorter wait for timeouts
response.Suggestions!["action"] = "retry_soon";
}
else if (exception.Message.Contains("network", StringComparison.OrdinalIgnoreCase) ||
exception.Message.Contains("connection", StringComparison.OrdinalIgnoreCase))
{
response.Suggestions!["retryAfter"] = 120; // Longer wait for network issues
response.Suggestions!["action"] = "check_network_and_retry";
}
}
return response;
}
/// <summary>
/// Creates an error response for internal server errors.
/// </summary>
public static ApiErrorResponse InternalError(string message, Exception? exception = null)
{
var response = new ApiErrorResponse
{
ErrorCode = "INTERNAL_ERROR",
ErrorType = "ServiceError",
Message = message
};
if (exception != null)
{
response.Details = new Dictionary<string, object>
{
{ "exceptionType", exception.GetType().Name },
{ "exceptionMessage", exception.Message }
};
}
return response;
}
/// <summary>
/// Creates an error response for when a specific resource is not found.
/// </summary>
public static ApiErrorResponse NotFound(string resourceType, string identifier, string? suggestion = null)
{
var response = new ApiErrorResponse
{
ErrorCode = "NOT_FOUND",
ErrorType = "NotFoundError",
Message = $"{resourceType} not found: {identifier}",
Details = new Dictionary<string, object>
{
{ "resourceType", resourceType },
{ "identifier", identifier }
}
};
if (!string.IsNullOrEmpty(suggestion))
{
response.Suggestions = new Dictionary<string, object> { { "suggestion", suggestion } };
}
return response;
}
/// <summary>
/// Creates an error response for time range validation errors.
/// </summary>
public static ApiErrorResponse TimeRangeError(string message, object? availableRange = null, object? requestedRange = null)
{
var response = new ApiErrorResponse
{
ErrorCode = "TIME_RANGE_ERROR",
ErrorType = "ValidationError",
Message = message,
Details = new Dictionary<string, object>(),
Suggestions = new Dictionary<string, object>
{
{ "action", "adjust_time_range" }
}
};
if (availableRange != null)
{
response.Details["availableRange"] = availableRange;
// Add suggestion to use available range if provided
// Try to extract oldest/newest from the availableRange object using reflection
try
{
var rangeType = availableRange.GetType();
var oldestProp = rangeType.GetProperty("oldest");
var newestProp = rangeType.GetProperty("newest");
if (oldestProp != null && newestProp != null)
{
var oldest = oldestProp.GetValue(availableRange);
var newest = newestProp.GetValue(availableRange);
if (oldest != null && newest != null)
{
response.Suggestions!["suggestedRange"] = new
{
start = oldest,
end = newest
};
response.Suggestions!["suggestion"] = $"Try querying data between {oldest} and {newest}";
}
}
}
catch
{
// Ignore if we can't extract range info
}
}
if (requestedRange != null)
{
response.Details["requestedRange"] = requestedRange;
}
return response;
}
}
+37
View File
@@ -1,5 +1,15 @@
namespace BomLocalService.Models; namespace BomLocalService.Models;
/// <summary>
/// Represents the current phase of a cache update operation.
/// </summary>
public enum CacheUpdatePhase
{
Initializing, // Browser setup, navigation (0-20% of time)
CapturingFrames, // Frame capture loop (20-95% of time)
Saving // Metadata, cleanup (95-100% of time)
}
/// <summary> /// <summary>
/// Status information about a cache update operation for a location. /// Status information about a cache update operation for a location.
/// Returned when manually triggering a cache refresh via the refresh endpoint. /// Returned when manually triggering a cache refresh via the refresh endpoint.
@@ -51,7 +61,34 @@ public class CacheUpdateStatus
/// - "Cache is stale, update triggered" - Cache exists but expired, update initiated /// - "Cache is stale, update triggered" - Cache exists but expired, update initiated
/// - "No cache exists, update triggered" - No cache found, update initiated /// - "No cache exists, update triggered" - No cache found, update initiated
/// - "Cache update already in progress" - An update is currently running /// - "Cache update already in progress" - An update is currently running
/// - "Cache update failed" - An update was attempted but failed (check Error property)
/// </summary> /// </summary>
public string? Message { get; set; } public string? Message { get; set; }
/// <summary>
/// Indicates whether the last cache update attempt failed.
/// True if an update was triggered but encountered an error.
/// </summary>
public bool UpdateFailed { get; set; }
/// <summary>
/// Error information if the cache update failed.
/// Contains error code, message, and details about what went wrong.
/// Null if no error occurred or if update hasn't been attempted yet.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// Error code for programmatic handling of update failures.
/// Examples: "SCRAPING_ERROR", "BROWSER_ERROR", "STORAGE_ERROR", "TIMEOUT_ERROR"
/// Null if no error occurred.
/// </summary>
public string? ErrorCode { get; set; }
/// <summary>
/// Timestamp when the last update attempt was made (UTC).
/// Null if no update has been attempted yet.
/// </summary>
public DateTime? LastUpdateAttempt { get; set; }
} }
+3 -2
View File
@@ -11,8 +11,9 @@ public class FrameMetadata
public int FrameIndex { get; set; } public int FrameIndex { get; set; }
/// <summary> /// <summary>
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10). /// The absolute UTC observation time for this frame.
/// Parsed directly from the frame display label during capture.
/// </summary> /// </summary>
public int MinutesAgo { get; set; } public DateTime ObservationTime { get; set; }
} }
+187
View File
@@ -0,0 +1,187 @@
namespace BomLocalService.Models;
/// <summary>
/// JavaScript code templates used for page evaluation
/// </summary>
public class JavaScriptTemplatesConfig
{
public string WaitForSearchResults { get; set; } = @"() => {
const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=""listitem""]'));
return results.length > 0 && results.some(r => r.offsetParent !== null);
}";
public string ExtractSearchResults { get; set; } = @"() => {
const resultsList = document.querySelector('ul[aria-labelledby=""location-results-title""]');
if (!resultsList) {
console.log('Location results list not found');
return [];
}
const results = Array.from(resultsList.querySelectorAll('li.bom-linklist__item[role=""listitem""]'));
console.log('Found', results.length, 'location results');
return results.map((r) => {
const nameEl = r.querySelector('[data-testid=""location-name""]');
const descEl = r.querySelector('.bom-linklist-item__desc');
const name = nameEl ? (nameEl.textContent || nameEl.innerText || '').trim() : '';
const desc = descEl ? (descEl.textContent || descEl.innerText || '').trim() : '';
const fullText = (r.textContent || r.innerText || '').trim();
console.log('Result:', { hasNameEl: !!nameEl, hasDescEl: !!descEl, name: name, desc: desc });
return [name, desc, fullText];
});
}";
public string ExtractSearchResultsFallback { get; set; } = @"() => {
const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=""listitem""]'));
return results.map(r => r.textContent || '');
}";
public string WaitForMapCanvas { get; set; } = @"() => {
const canvas = document.querySelector('.esri-view-surface canvas');
return canvas && canvas.width > 0 && canvas.height > 0 && canvas.offsetWidth > 0 && canvas.offsetHeight > 0;
}";
public string WaitForEsriView { get; set; } = @"() => {
try {
const elements = document.querySelectorAll('.esri-view');
for (let el of elements) {
if (el.__view && el.__view.ready) {
return true;
}
}
} catch(e) {}
return false;
}";
public string CheckActiveFrameSegment { get; set; } = @"() => {
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
if (segments.length === 0) return true;
const activeSegment = segments.find(s => {
const style = window.getComputedStyle(s);
return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
});
return activeSegment && activeSegment.getAttribute('data-id') === '0';
}";
public string WaitForMapContainer { get; set; } = @"() => {
const container = document.querySelector('.esri-view-surface');
return container && container.offsetWidth > 0 && container.offsetHeight > 0;
}";
public string ExtractFrameInfo { get; set; } = @"() => {
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
const now = new Date();
return segments.map((seg, index) => {
const ariaLabel = seg.getAttribute('aria-label') || '';
let minutes = null;
// Parse timestamp format: ""Wednesday 17 Dec, 11:05 pm"" or ""17 Dec, 11:05 pm""
const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\s+)?(\d{1,2})\s+([A-Za-z]+),?\s+(\d{1,2}):(\d{2})\s*(am|pm)/i);
if (timestampMatch) {
try {
const day = parseInt(timestampMatch[1]);
const monthStr = timestampMatch[2];
const hour12 = parseInt(timestampMatch[3]);
const minute = parseInt(timestampMatch[4]);
const ampm = timestampMatch[5].toLowerCase();
const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const month = monthNames.indexOf(monthStr.toLowerCase());
if (month >= 0) {
let hour24 = hour12;
if (ampm === 'pm' && hour12 !== 12) hour24 += 12;
if (ampm === 'am' && hour12 === 12) hour24 = 0;
const year = now.getFullYear();
const frameTime = new Date(year, month, day, hour24, minute);
// If parsed time is in future, assume it's from last year
if (frameTime > now) {
frameTime.setFullYear(year - 1);
}
const diffMs = now - frameTime;
minutes = Math.round(diffMs / (1000 * 60));
// Validate reasonable range (0-2 hours)
if (minutes < 0 || minutes > 120) {
minutes = null;
}
}
} catch(e) {
// Parse error, leave minutes as null
}
}
return { index: index, minutesAgo: minutes };
});
}";
public string CheckModalOverlay { get; set; } = @"() => {
const bomOverlay = document.querySelector('.bom-modal-overlay--after-open');
if (bomOverlay && bomOverlay.style.display !== 'none') {
return true;
}
const recaptchaSelectors = ['.g-recaptcha', '#g-recaptcha', '.rc-anchor-container'];
for (const selector of recaptchaSelectors) {
const el = document.querySelector(selector);
if (el) {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (style.display !== 'none' && style.visibility !== 'hidden' &&
rect.width > 200 && rect.height > 200) {
return true;
}
}
}
const allForms = document.querySelectorAll('form');
for (const form of allForms) {
const action = form.getAttribute('action') || '';
const id = form.getAttribute('id') || '';
if (action.includes('feedback') || id.includes('feedback')) {
const style = window.getComputedStyle(form);
const rect = form.getBoundingClientRect();
if (style.display !== 'none' && rect.width > 200 && rect.height > 200) {
const text = form.textContent || '';
if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) {
return true;
}
}
}
}
return false;
}";
public string CheckModalStillVisible { get; set; } = @"() => {
const bomOverlay = document.querySelector('.bom-modal-overlay--after-open');
if (bomOverlay && bomOverlay.style.display !== 'none') return true;
const allForms = document.querySelectorAll('form');
for (const form of allForms) {
const action = form.getAttribute('action') || '';
const id = form.getAttribute('id') || '';
if (action.includes('feedback') || id.includes('feedback')) {
const style = window.getComputedStyle(form);
const rect = form.getBoundingClientRect();
if (style.display !== 'none' && rect.width > 200 && rect.height > 200) {
return true;
}
}
}
return false;
}";
public string GetViewportSize { get; set; } = @"() => JSON.stringify({ width: window.innerWidth, height: window.innerHeight })";
public string ExtractWeatherMetadata { get; set; } = @"() => {
const section = document.querySelector('section[data-testid=""weatherMetadata""]') ||
document.querySelector('section[aria-label=""Last updated""]');
if (!section) return null;
const divs = section.querySelectorAll('div');
return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' ');
}";
}
+2 -10
View File
@@ -2,22 +2,14 @@ namespace BomLocalService.Models;
/// <summary> /// <summary>
/// Represents a single radar frame (historical precipitation data). /// Represents a single radar frame (historical precipitation data).
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
/// Radar frames can be joined across cache folders to create extended historical slideshows. /// Radar frames can be joined across cache folders to create extended historical slideshows.
/// </summary> /// </summary>
public class RadarFrame : CachedFrame public class RadarFrame : CachedFrame
{ {
/// <summary>
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
/// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
/// This is relative to the cache folder's observation time.
/// </summary>
public int MinutesAgo { get; set; }
/// <summary> /// <summary>
/// The absolute UTC observation time for this frame. /// The absolute UTC observation time for this frame.
/// Calculated as: ObservationTime - MinutesAgo. /// Parsed directly from the frame display label during capture.
/// This is set when frames are joined across cache folders in timeseries responses. /// Client calculates "minutes ago" dynamically from this timestamp.
/// </summary> /// </summary>
public DateTime? AbsoluteObservationTime { get; set; } public DateTime? AbsoluteObservationTime { get; set; }
+2 -1
View File
@@ -8,7 +8,8 @@ public class RadarResponse
{ {
/// <summary> /// <summary>
/// List of all captured frames (typically 7 frames: 0-6). /// List of all captured frames (typically 7 frames: 0-6).
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago). /// Each frame contains an absoluteObservationTime UTC timestamp.
/// Client should calculate "minutes ago" dynamically from the timestamp.
/// </summary> /// </summary>
public List<RadarFrame> Frames { get; set; } = new(); public List<RadarFrame> Frames { get; set; } = new();
+26
View File
@@ -0,0 +1,26 @@
namespace BomLocalService.Models;
/// <summary>
/// Configuration for all selectors used in scraping
/// </summary>
public class ScrapingSelectorsConfig
{
public SelectorConfig SearchButton { get; set; } = new();
public SelectorConfig SearchInput { get; set; } = new();
public SelectorConfig SearchResultsList { get; set; } = new();
public SelectorConfig SearchResultItem { get; set; } = new();
public SelectorConfig LocationName { get; set; } = new();
public SelectorConfig LocationDescription { get; set; } = new();
public SelectorConfig ResultsTitle { get; set; } = new();
public SelectorConfig RadarLink { get; set; } = new();
public SelectorConfig MapCanvas { get; set; } = new();
public SelectorConfig MapContainer { get; set; } = new();
public SelectorConfig PlayPauseButton { get; set; } = new();
public SelectorConfig PlayPauseLabel { get; set; } = new();
public SelectorConfig FrameSegment { get; set; } = new();
public SelectorConfig StepForwardButton { get; set; } = new();
public SelectorConfig TimeDisplayLabel { get; set; } = new();
public SelectorConfig ModalOverlay { get; set; } = new();
public SelectorConfig WeatherMetadata { get; set; } = new();
}
+18
View File
@@ -0,0 +1,18 @@
namespace BomLocalService.Models;
/// <summary>
/// Configuration for a single scraping step
/// </summary>
public class ScrapingStepConfig
{
/// <summary>
/// Whether this step is enabled (can be disabled without removing from workflow)
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Optional parameters specific to this step
/// </summary>
public Dictionary<string, object>? Parameters { get; set; }
}
+11
View File
@@ -0,0 +1,11 @@
namespace BomLocalService.Models;
/// <summary>
/// Configuration for a scraping workflow
/// </summary>
public class ScrapingWorkflowConfig
{
public string Description { get; set; } = string.Empty;
public Dictionary<string, ScrapingStepConfig> Steps { get; set; } = new();
}
+33
View File
@@ -0,0 +1,33 @@
namespace BomLocalService.Models;
/// <summary>
/// Configuration for a single selector with multiple fallback options
/// </summary>
public class SelectorConfig
{
/// <summary>
/// Human-readable name for this selector (for logging/debugging)
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Array of CSS selectors to try in order (first match wins)
/// </summary>
public string[] Selectors { get; set; } = Array.Empty<string>();
/// <summary>
/// Timeout in milliseconds when waiting for this element
/// </summary>
public int TimeoutMs { get; set; } = 5000;
/// <summary>
/// Whether this element is required (throws if not found)
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// Custom error message if element is not found (when Required=true)
/// </summary>
public string? ErrorMessage { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
namespace BomLocalService.Models;
/// <summary>
/// Text patterns and regex configurations for parsing page content
/// </summary>
public class TextPatternsConfig
{
public string ResultsCountPattern { get; set; } = @"(\d+)\s+of\s+(\d+)";
public string TimestampPattern { get; set; } = @"(?:[A-Za-z]+\s+)?\d{1,2}\s+[A-Za-z]+,?\s+\d{1,2}:\d{2}\s*(?:am|pm)";
public string ObservationTimePattern { get; set; } = @"Observations:\s*(\d+)\s*minutes?\s*ago";
public string ForecastTimePattern { get; set; } = @"Forecast:\s*(\d+)\s*minutes?\s+ago";
public string ForecastHourAgoPattern { get; set; } = @"Forecast:\s*an\s+hour\s+ago";
public string WeatherStationPattern { get; set; } = @"at\s+([^,]+)\s+weather\s+station";
public string DistancePattern { get; set; } = @"(\d+)\s*km\s+from";
public Dictionary<string, string> ExpectedTexts { get; set; } = new()
{
["PlayButtonLabel"] = "Play",
["PauseButtonLabel"] = "Pause",
["RadarLinkText"] = "Rain radar and weather map"
};
}
+20 -27
View File
@@ -1,4 +1,4 @@
using BomLocalService.Services; using BomLocalService.Extensions;
using BomLocalService.Services.Interfaces; using BomLocalService.Services.Interfaces;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -7,44 +7,34 @@ var builder = WebApplication.CreateBuilder(args);
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers // AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi(); builder.Services.AddHealthChecks();
// Register core services via interfaces (order matters - dependencies must be registered first) // Configure CORS - MUST be added before other services
builder.Services.AddSingleton<IDebugService, DebugService>(); builder.Services.AddCorsConfiguration(builder.Configuration);
builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddSingleton<ITimeParsingService, TimeParsingService>();
builder.Services.AddSingleton<IBrowserService, BrowserService>();
builder.Services.AddSingleton<IScrapingService, ScrapingService>();
// Register BOM Radar Service as singleton (orchestrator, depends on all above services) // Register all services via assembly scanning
builder.Services.AddSingleton<IBomRadarService, BomLocalService.Services.BomRadarService>(); builder.Services.ScanAndRegisterServices(typeof(Program).Assembly);
// Register background services (order matters - management service needs radar service)
builder.Services.AddHostedService<CacheCleanupService>();
builder.Services.AddHostedService<CacheManagementService>();
// Add configuration - appsettings.json provides default values
// All values can be overridden via environment variables
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline // Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// HTTPS redirection is optional and disabled by default for Docker flexibility // HTTPS redirection is optional and disabled by default for Docker flexibility
// Users can enable it by setting ENABLE_HTTPS_REDIRECTION=true environment variable // Users can enable it by setting EnableHttpsRedirection=true in appsettings.json or ENABLEHTTPSREDIRECTION=true environment variable
// or EnableHttpsRedirection=true in appsettings.json var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false);
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false) ||
Environment.GetEnvironmentVariable("ENABLE_HTTPS_REDIRECTION")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
if (enableHttpsRedirection) if (enableHttpsRedirection)
{ {
app.UseHttpsRedirection(); app.UseHttpsRedirection();
} }
// Enable routing (required for CORS to work with controllers)
app.UseRouting();
// Static files (for wwwroot assets like CSS, JS, images)
app.UseStaticFiles();
// CORS middleware - MUST be after UseRouting but before MapControllers
app.UseCors();
// No authorization required - service is designed to run behind a reverse proxy if auth is needed // No authorization required - service is designed to run behind a reverse proxy if auth is needed
// Map MVC routes first (before API routes to avoid conflicts) // Map MVC routes first (before API routes to avoid conflicts)
@@ -55,6 +45,9 @@ app.MapControllerRoute(
// Map API controllers (with /api prefix) // Map API controllers (with /api prefix)
app.MapControllers(); app.MapControllers();
// Map health check endpoint for Docker health monitoring
app.MapHealthChecks("/api/health");
// Cleanup incomplete cache folders from previous crashes/restarts before starting services // Cleanup incomplete cache folders from previous crashes/restarts before starting services
var cacheService = app.Services.GetRequiredService<ICacheService>(); var cacheService = app.Services.GetRequiredService<ICacheService>();
var deletedCount = cacheService.CleanupIncompleteCacheFolders(); var deletedCount = cacheService.CleanupIncompleteCacheFolders();
+607 -30
View File
@@ -1,3 +1,7 @@
<p align="center">
<img width="256" height="auto" alt="icon" src="https://github.com/user-attachments/assets/6f712fbd-4f42-49d4-9ddb-1767bb43551a" />
</p>
# BOM Local Service # BOM Local Service
A local caching service for Australian Bureau of Meteorology (BOM) radar data, designed to provide a reliable API for Home Assistant integrations and other local services. A local caching service for Australian Bureau of Meteorology (BOM) radar data, designed to provide a reliable API for Home Assistant integrations and other local services.
@@ -33,10 +37,25 @@ Built on ASP.NET Core 9.0, the service uses a service-oriented architecture with
- **BomRadarService**: Main orchestrator that coordinates cache operations, browser automation, and data retrieval - **BomRadarService**: Main orchestrator that coordinates cache operations, browser automation, and data retrieval
- **CacheService**: Manages file-based storage of radar screenshots and metadata in organized directory structures - **CacheService**: Manages file-based storage of radar screenshots and metadata in organized directory structures
- **BrowserService**: Handles Playwright browser automation for headless browser sessions - **BrowserService**: Handles Playwright browser automation for headless browser sessions
- **ScrapingService**: Performs web scraping operations using the browser service to navigate and capture radar data from the BOM website - **ScrapingService**: Coordinates web scraping workflows (simplified orchestrator)
- **SelectorService**: Finds page elements using configurable CSS selectors with fallback support
- **TimeParsingService**: Parses and converts time formats from BOM data - **TimeParsingService**: Parses and converts time formats from BOM data
- **DebugService**: Provides debug functionality for troubleshooting - **DebugService**: Provides debug functionality for troubleshooting
### Scraping Architecture
The scraping system uses a **workflow-based architecture** with configurable steps:
- **Workflows**: Define fixed sequences of steps for different data types (e.g., `RadarScrapingWorkflow`, `TemperatureMapWorkflow`). Each workflow specifies its response type via generics (`IWorkflow<TResponse>`)
- **Steps**: Individual, testable units that perform specific actions (navigation, search, map interaction, capture). Steps declare prerequisites and validate page state before execution
- **Step Registry**: Manages and discovers available scraping steps
- **Workflow Factory**: Creates typed workflow instances based on configuration
**Configuration-Driven Design:**
- **Selectors**: All CSS selectors are configurable via `appsettings.json` with fallback options, allowing adaptation to website changes without code modifications
- **JavaScript Templates**: JavaScript code for page evaluation is externalized in configuration, making it easy to update logic as the website evolves
- **Text Patterns**: Regex patterns for parsing page content are configurable, enabling quick adjustments to parsing logic
- **Workflow Steps**: Individual steps within workflows can be enabled/disabled via configuration, providing flexibility for testing and troubleshooting
### Background Services ### Background Services
- **CacheManagementService**: Periodically checks cache validity for all cached locations and triggers updates when data expires - **CacheManagementService**: Periodically checks cache validity for all cached locations and triggers updates when data expires
- **CacheCleanupService**: Removes cache files older than the configured retention period - **CacheCleanupService**: Removes cache files older than the configured retention period
@@ -155,11 +174,23 @@ If you want to build locally, keep the `build:` section in `docker-compose.yml`
docker-compose up -d docker-compose up -d
``` ```
**Mounting custom appsettings.json:**
To use a custom configuration file, add it to the volumes section in `docker-compose.yml`:
```yaml
services:
bom-local-service:
volumes:
- ./cache:/app/cache
- ./appsettings.json:/app/appsettings.json:ro # Custom config
```
This will: This will:
- Pull/build the image as configured - Pull/build the image as configured
- Start the service on port 8082 (configurable via `HOST_PORT`) - Start the service on port 8082 (configurable via `HOST_PORT`)
- Mount the `./cache` directory for persistent storage - Mount the `./cache` directory for persistent storage
- Apply all environment variable configurations - Mount custom `appsettings.json` if specified
- Apply all environment variable configurations (which override appsettings.json)
To view logs: To view logs:
```bash ```bash
@@ -183,14 +214,14 @@ All configuration can be done via environment variables, which override the defa
|----------|-------------|---------| |----------|-------------|---------|
| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` | | `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` |
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` | | `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
| `ENABLE_HTTPS_REDIRECTION` | Enable HTTPS redirection | `false` | | `ENABLEHTTPSREDIRECTION` | Enable HTTPS redirection | `false` |
#### Application Configuration #### Application Configuration
| Variable | Description | Default | Example | | Variable | Description | Default | Example |
|----------|-------------|---------|---------| |----------|-------------|---------|---------|
| `CACHEDIRECTORY` | Directory path for cache storage | `/app/cache` | `/data/bom-cache` | | `CACHEDIRECTORY` | Directory path for cache storage | `/app/cache` | `/data/bom-cache` |
| `CACHERETENTIONHOURS` | Hours to retain cached data before cleanup | `24` | `48` | | `CACHERETENTIONHOURS` | Hours to retain cached data before cleanup (can be any positive integer) | `24` | `48`, `72`, `168` (1 week) |
| `CACHEEXPIRATIONMINUTES` | Minutes before cache is considered expired | `12.5` | `15` | | `CACHEEXPIRATIONMINUTES` | Minutes before cache is considered expired | `12.5` | `15` |
| `TIMEZONE` | Timezone for time parsing (IANA format) | `Australia/Brisbane` | `Australia/Sydney` | | `TIMEZONE` | Timezone for time parsing (IANA format) | `Australia/Brisbane` | `Australia/Sydney` |
@@ -200,8 +231,7 @@ All configuration can be done via environment variables, which override the defa
|----------|-------------|---------|---------| |----------|-------------|---------|---------|
| `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` | | `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` |
| `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` | | `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` |
| `CACHEMANAGEMENT__UPDATESTAGGERSECONDS` | Delay between triggering cache updates | `2` | `5` | | `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations (used for both initial and periodic updates) | `1` | `2` |
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations | `1` | `2` |
#### Cache Cleanup #### Cache Cleanup
@@ -213,8 +243,50 @@ All configuration can be done via environment variables, which override the defa
| Variable | Description | Default | Example | | Variable | Description | Default | Example |
|----------|-------------|---------|---------| |----------|-------------|---------|---------|
| `SCREENSHOT__DYNAMICCONTENTWAITMS` | Milliseconds to wait for dynamic content to load | `2000` | `3000` | | `SCREENSHOT__DYNAMICCONTENTWAITMS` | Milliseconds to wait for dynamic content to load | `1500` | `2000` |
| `SCREENSHOT__TILERENDERWAITMS` | Milliseconds to wait for map tiles to render | `5000` | `7000` | | `SCREENSHOT__TILERENDERWAITMS` | Milliseconds to wait for map tiles to render | `3000` | `5000` |
| `SCREENSHOT__CROP__X` | X offset in pixels for screenshot cropping | `250` | `300` |
| `SCREENSHOT__CROP__Y` | Y offset in pixels for screenshot cropping | `0` | `50` |
| `SCREENSHOT__CROP__RIGHTOFFSET` | Right offset in pixels for screenshot cropping | `250` | `300` |
| `SCREENSHOT__CROP__HEIGHT` | Height in pixels for screenshot cropping (null = full height) | `null` | `800` |
#### Scraping Configuration
The scraping system is highly configurable through `appsettings.json`. Most scraping settings (selectors, JavaScript templates, text patterns, workflow steps) are configured in `appsettings.json`, but can be overridden in Docker deployments.
**Option 1: Mount Custom appsettings.json (Recommended for Docker)**
Mount a custom `appsettings.json` file as a volume:
```bash
docker run -d \
--name bom-local-service \
-p 8082:8080 \
-v $(pwd)/cache:/app/cache \
-v $(pwd)/appsettings.json:/app/appsettings.json:ro \
--shm-size=1gb \
--ipc=host \
ghcr.io/alexhopeoconnor/bom-local-service:latest
```
Or in `docker-compose.yml`:
```yaml
services:
bom-local-service:
volumes:
- ./cache:/app/cache
- ./appsettings.json:/app/appsettings.json:ro # Add this line
```
**Option 2: Environment Variables (Simple Overrides)**
For quick overrides of commonly needed settings:
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `SCRAPING__BASEURL` | Base URL for the BOM website | `https://www.bom.gov.au/` | `https://www.bom.gov.au/` |
**Note**: Complex configurations (selectors, JavaScript templates, text patterns, workflow steps) are best managed via a mounted `appsettings.json` file. See the [Configuration File](#configuration-file) section below for the complete structure.
#### Debug Configuration #### Debug Configuration
@@ -223,6 +295,13 @@ All configuration can be done via environment variables, which override the defa
| `DEBUG__ENABLED` | Enable debug mode (saves debug screenshots) | `false` | `true` | | `DEBUG__ENABLED` | Enable debug mode (saves debug screenshots) | `false` | `true` |
| `DEBUG__WAITMS` | Additional wait time in debug mode | `2000` | `5000` | | `DEBUG__WAITMS` | Additional wait time in debug mode | `2000` | `5000` |
#### Time Series Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `TIMESERIES__WARNINGFOLDERCOUNT` | Number of cache folders that triggers a warning log when processing time series requests | `200` | `300` |
| `TIMESERIES__MAXTIMERANGEHOURS` | Maximum time range allowed for time series queries (null = use CacheRetentionHours) | `null` (uses CacheRetentionHours) | `72` |
#### Docker Compose Port Configuration #### Docker Compose Port Configuration
| Variable | Description | Default | | Variable | Description | Default |
@@ -291,6 +370,126 @@ Then run:
docker-compose up -d docker-compose up -d
``` ```
#### Custom appsettings.json (For Selector/Scraping Configuration)
If you need to customize selectors, JavaScript templates, or workflow steps:
1. **Copy the default appsettings.json** from the repository
2. **Edit the sections you need** (e.g., `Scraping:Selectors`)
3. **Mount it as a volume**:
```bash
docker run -d \
--name bom-local-service \
-p 8082:8080 \
-v $(pwd)/cache:/app/cache \
-v $(pwd)/appsettings.json:/app/appsettings.json:ro \
--shm-size=1gb \
--ipc=host \
ghcr.io/alexhopeoconnor/bom-local-service:latest
```
Or in `docker-compose.yml`:
```yaml
services:
bom-local-service:
volumes:
- ./cache:/app/cache
- ./appsettings.json:/app/appsettings.json:ro
```
**Note**: Environment variables still override values in the mounted `appsettings.json`, so you can use env vars for simple overrides and the mounted file for complex configurations.
### Configuration File
For advanced scraping configuration, edit `appsettings.json` directly. When using Docker, you can mount a custom `appsettings.json` file as a volume (see [Scraping Configuration](#scraping-configuration) above).
The scraping system supports extensive configuration:
#### Scraping Selectors
All CSS selectors used to find page elements are configurable with fallback options:
```json
{
"Scraping": {
"Selectors": {
"SearchButton": {
"Name": "Search Button",
"Selectors": [
"button[data-testid='searchLabel']",
"button[aria-label='Search for a location']",
"button.search-location__trigger-button"
],
"TimeoutMs": 10000,
"Required": true,
"ErrorMessage": "Could not find search button"
}
}
}
}
```
#### JavaScript Templates
JavaScript code used for page evaluation is externalized and configurable:
```json
{
"Scraping": {
"JavaScriptTemplates": {
"WaitForSearchResults": "() => { /* template code */ }",
"ExtractSearchResults": "() => { /* template code */ }"
}
}
}
```
#### Text Patterns
Regex patterns for parsing page content are configurable:
```json
{
"Scraping": {
"TextPatterns": {
"ResultsCountPattern": "(\\d+)\\s+of\\s+(\\d+)",
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]{3},?\\s+\\d{1,2}:\\d{2}\\s+(?:am|pm)",
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]{3},?\\s+\\d{1,2}:\\d{2}\\s+(?:am|pm)"
}
}
}
```
#### Workflow Steps
Individual workflow steps can be enabled/disabled and configured:
```json
{
"Scraping": {
"Workflows": {
"RadarScraping": {
"Description": "Scrapes radar images for a location",
"Steps": {
"NavigateHomepage": { "Enabled": true },
"ClickSearchButton": { "Enabled": true },
"CaptureFrames": {
"Enabled": true,
"Parameters": {
"FrameCount": 7,
"WaitBetweenFrames": 5000
}
}
}
}
}
}
}
```
**Note**: Step order is fixed within workflows due to dependencies. Steps can be disabled but not reordered. See `appsettings.json` for the complete configuration structure.
## API Documentation ## API Documentation
The service provides RESTful API endpoints for accessing radar data and managing the cache. The service provides RESTful API endpoints for accessing radar data and managing the cache.
@@ -320,8 +519,7 @@ GET /api/radar/{suburb}/{state}
{ {
"frameIndex": 0, "frameIndex": 0,
"imageUrl": "/api/radar/Brisbane/QLD/frame/0", "imageUrl": "/api/radar/Brisbane/QLD/frame/0",
"minutesAgo": 0, "absoluteObservationTime": "2025-01-15T10:00:00Z"
"observationTime": "2025-01-15T10:00:00Z"
} }
], ],
"observationTime": "2025-01-15T10:00:00Z", "observationTime": "2025-01-15T10:00:00Z",
@@ -335,10 +533,56 @@ GET /api/radar/{suburb}/{state}
} }
``` ```
**Response Fields:**
- `frames`: Array of radar frame objects with image URLs. Each frame contains `absoluteObservationTime` (UTC timestamp). Client should calculate "minutes ago" dynamically from this timestamp.
- `observationTime`: UTC timestamp when the observation was made
- `forecastTime`: UTC timestamp for the forecast
- `weatherStation`: Name of the weather station
- `distance`: Distance from location to weather station
- `cacheIsValid`: Whether the cache is still valid (not expired)
- `cacheExpiresAt`: UTC timestamp when the cache expires
- `isUpdating`: Whether a cache update is currently in progress
- `nextUpdateTime`: **Estimated** UTC timestamp for when the cache will be updated or when an in-progress update will complete. This value is calculated using:
- **Metrics-based estimation** (preferred): When historical data is available, uses median durations from previous cache updates to provide hardware-adaptive estimates
- **Calculated estimation** (fallback): When no metrics are available yet (e.g., first update), calculates based on configured wait times and frame count
- **Progress-aware**: During active updates, estimates improve as progress is tracked through phases (Initializing → CapturingFrames → Saving)
**Status Codes:** **Status Codes:**
- `200 OK`: Radar data available - `200 OK`: Radar data available
- `404 Not Found`: Cache is being generated (check response for retry information) - `404 Not Found`: Cache is being generated (check response for retry information)
```json
{
"errorCode": "CACHE_NOT_FOUND",
"errorType": "CacheError",
"message": "No cached data found for this location (fresh start). Cache update has been triggered in background.",
"details": {
"location": { "suburb": "Brisbane", "state": "QLD" },
"cacheExists": false,
"cacheIsValid": false,
"updateTriggered": true,
"nextUpdateTime": "2025-01-15T10:12:30Z"
},
"suggestions": {
"action": "retry_after_seconds",
"retryAfter": 30,
"refreshEndpoint": "/api/cache/Brisbane/QLD/refresh"
},
"note": "The retryAfter value is dynamically calculated based on the estimated cache update duration. On first startup with no cache, it uses a calculated estimate. After metrics are collected from completed updates, it uses hardware-adaptive estimates based on actual performance."
"timestamp": "2025-01-15T10:00:00Z"
}
```
- `400 Bad Request`: Invalid location parameters - `400 Bad Request`: Invalid location parameters
```json
{
"errorCode": "VALIDATION_ERROR",
"errorType": "ValidationError",
"message": "Invalid state abbreviation. Use: NSW, VIC, QLD, SA, WA, TAS, NT, ACT",
"details": {
"field": "state"
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
#### Get Frame Image #### Get Frame Image
@@ -357,6 +601,23 @@ GET /api/radar/{suburb}/{state}/frame/{frameIndex}
**Response:** **Response:**
- `200 OK`: PNG image - `200 OK`: PNG image
- `404 Not Found`: Frame not found - `404 Not Found`: Frame not found
```json
{
"errorCode": "NOT_FOUND",
"errorType": "NotFoundError",
"message": "Frame 3 not found for Brisbane, QLD",
"details": {
"resourceType": "Frame",
"identifier": "Frame 3 for Brisbane, QLD",
"frameIndex": 3,
"location": { "suburb": "Brisbane", "state": "QLD" }
},
"suggestions": {
"suggestion": "The frame may not exist yet. Try refreshing the cache or checking if cache update is in progress."
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
#### Get Metadata #### Get Metadata
@@ -391,7 +652,7 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
- `startTime` (query, optional): ISO 8601 start time (e.g., `2025-01-15T00:00:00Z`) - `startTime` (query, optional): ISO 8601 start time (e.g., `2025-01-15T00:00:00Z`)
- `endTime` (query, optional): ISO 8601 end time (defaults to now) - `endTime` (query, optional): ISO 8601 end time (defaults to now)
**Response:** **Response (200 OK):**
```json ```json
{ {
"cacheFolders": [ "cacheFolders": [
@@ -403,17 +664,152 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
{ {
"frameIndex": 0, "frameIndex": 0,
"imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000", "imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000",
"absoluteObservationTime": "2025-01-15T10:00:00Z", "absoluteObservationTime": "2025-01-15T10:00:00Z"
"minutesAgo": 0
} }
] ]
} }
], ],
"totalFrames": 7, "startTime": "2025-01-15T07:00:00Z",
"timeSpanMinutes": 60 "endTime": "2025-01-15T10:00:00Z",
"totalFrames": 7
} }
``` ```
**Status Codes:**
- `200 OK`: Historical data available
- `400 Bad Request`: Invalid request (e.g., time range exceeds maximum allowed duration, invalid time format, startTime after endTime)
- **Invalid time format**:
```json
{
"errorCode": "VALIDATION_ERROR",
"errorType": "ValidationError",
"message": "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)",
"details": {
"field": "startTime"
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
- **Time range exceeds maximum**:
```json
{
"errorCode": "TIME_RANGE_ERROR",
"errorType": "ValidationError",
"message": "Time range exceeds maximum allowed duration of 24 hours (based on cache retention: 24 hours). Please specify a smaller range.",
"details": {
"requestedRange": {
"start": "2025-01-15T00:00:00Z",
"end": "2025-01-15T25:00:00Z",
"requestedHours": 25.0
}
},
"suggestions": {
"action": "adjust_time_range"
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
- `404 Not Found`:
- **Location not cached**: No cache exists for this location. Cache update is triggered in background:
```json
{
"errorCode": "CACHE_NOT_FOUND",
"errorType": "CacheError",
"message": "No cached data found for this location. Cache update has been triggered in background.",
"details": {
"location": { "suburb": "Brisbane", "state": "QLD" },
"cacheExists": false,
"cacheIsValid": false,
"updateTriggered": true,
"cacheExpiresAt": null,
"nextUpdateTime": "2025-01-15T10:12:30Z",
"statusMessage": "No cache exists, update triggered"
},
"suggestions": {
"action": "retry_after_seconds",
"retryAfter": 30,
"refreshEndpoint": "/api/cache/Brisbane/QLD/refresh",
"statusEndpoint": "/api/cache/Brisbane/QLD/range"
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
- **No data in range**: Cache exists but no data in the requested time range:
```json
{
"errorCode": "TIME_RANGE_ERROR",
"errorType": "ValidationError",
"message": "No historical data found for the specified time range.",
"details": {
"availableRange": {
"oldest": "2025-01-15T08:00:00Z",
"newest": "2025-01-15T10:00:00Z",
"totalCacheFolders": 10,
"timeSpanMinutes": 120
},
"requestedRange": {
"start": "2025-01-15T00:00:00Z",
"end": "2025-01-15T10:00:00Z"
}
},
"suggestions": {
"action": "adjust_time_range",
"suggestedRange": {
"start": "2025-01-15T08:00:00Z",
"end": "2025-01-15T10:00:00Z"
},
"suggestion": "Try querying data between 2025-01-15T08:00:00Z and 2025-01-15T10:00:00Z"
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
**Time Range Limits:**
- Maximum time range is configurable via `TimeSeries:MaxTimeRangeHours` (defaults to `CacheRetentionHours` or minimum 24 hours)
- If `TimeSeries:MaxTimeRangeHours` is not set, the limit automatically matches your `CacheRetentionHours` setting
- This ensures you can always query all available cached data (e.g., if retention is 72 hours, you can query up to 72 hours)
### Error Response Format
All API endpoints return standardized error responses using the `ApiErrorResponse` format:
```json
{
"errorCode": "CACHE_NOT_FOUND",
"errorType": "CacheError",
"message": "Human-readable error message",
"details": {
"location": { "suburb": "Brisbane", "state": "QLD" },
"cacheExists": false,
"cacheIsValid": false
},
"suggestions": {
"action": "retry_after_seconds",
"retryAfter": 30,
"refreshEndpoint": "/api/cache/Brisbane/QLD/refresh"
},
"timestamp": "2025-01-15T10:00:00Z"
}
```
**Error Response Fields:**
- `errorCode`: Machine-readable error code (e.g., `CACHE_NOT_FOUND`, `VALIDATION_ERROR`, `TIME_RANGE_ERROR`)
- `errorType`: Error category (`CacheError`, `ValidationError`, `ServiceError`, `NotFoundError`)
- `message`: Human-readable error description
- `details`: Additional context (varies by error type)
- `suggestions`: Actionable guidance (retry times, endpoints, etc.)
- `timestamp`: UTC timestamp when error occurred
**Common Error Codes:**
- `CACHE_NOT_FOUND`: No cached data exists for the location (fresh start scenario)
- `VALIDATION_ERROR`: Invalid request parameters
- `TIME_RANGE_ERROR`: Time range validation failed or no data in range
- `NOT_FOUND`: Specific resource not found (e.g., frame, metadata)
- `CACHE_UPDATE_FAILED`: Cache update operation failed
- `INTERNAL_ERROR`: Server-side error occurred
- If no time range is specified, returns all available historical data
- **Note**: `CacheRetentionHours` can be set to any positive integer value (24, 48, 72, 168, etc.)
#### Get Cache Range #### Get Cache Range
Get information about available historical cache data. Get information about available historical cache data.
@@ -457,6 +853,11 @@ POST /api/cache/{suburb}/{state}/refresh
} }
``` ```
**Note on `nextUpdateTime`**: The estimated completion time is calculated using:
- **Metrics-based estimation**: Uses historical median durations from previous cache updates (more accurate, hardware-adaptive)
- **Calculated estimation**: Falls back to calculated estimates based on configuration when no metrics are available yet
- Estimates improve in real-time as the update progresses through phases (Initializing → CapturingFrames → Saving)
#### Delete Cache #### Delete Cache
Delete cached data for a location. Delete cached data for a location.
@@ -472,12 +873,49 @@ DELETE /api/cache/{suburb}/{state}
} }
``` ```
### OpenAPI Documentation ## Cache Update Estimation
When running in Development mode, OpenAPI documentation is available at: The service uses a **metrics-based estimation system** to provide accurate estimates of cache update completion times. This ensures clients receive meaningful `nextUpdateTime` values that adapt to the actual hardware performance.
```
http://localhost:8082/openapi/v1.json ### How It Works
```
1. **Progress Tracking**: During cache updates, the service tracks progress through three phases:
- **Initializing**: Browser setup, navigation, and page loading (~0-20% of total time)
- **CapturingFrames**: Frame capture loop (~20-95% of total time)
- **Saving**: Metadata and cleanup operations (~95-100% of total time)
2. **Metrics Collection**: After each successful cache update, the service records:
- Total duration of the update
- Duration of each phase (Initializing, CapturingFrames, Saving)
- Duration of each individual scraping step (NavigateHomepage, ClickSearchButton, etc.)
- Frame-level progress during capture
3. **Estimation Strategy**:
- **Metrics-based** (preferred): Uses median durations from the last 20 completed updates to provide hardware-adaptive estimates
- **Progress-aware**: During active updates, estimates improve in real-time based on current phase and frame progress
- **Calculated fallback**: When no metrics are available (e.g., first update), falls back to calculated estimates based on configuration values
4. **Benefits**:
- **Hardware-adaptive**: Estimates automatically adjust to slower/faster hardware
- **Improves over time**: More accurate estimates as more updates complete
- **Real-time refinement**: Estimates become more precise as updates progress
- **Works from clean start**: Provides reasonable estimates even on first run
### Example Scenarios
**First Update (No Metrics)**:
- Uses calculated estimate based on `Screenshot:DynamicContentWaitMs`, `Screenshot:TileRenderWaitMs`, and frame count
- Example: ~100 seconds for 7 frames with default settings (optimized wait times)
**Subsequent Updates (With Metrics)**:
- Uses median duration from historical data
- Example: If previous updates averaged 95 seconds, estimates will use ~95 seconds (with buffer)
**In-Progress Update**:
- If capturing frame 3 of 7, estimates remaining time based on:
- Average frame duration from historical data
- Remaining frames (4 frames × avg frame duration)
- Plus estimated time for saving phase
## Demo SPA ## Demo SPA
@@ -505,11 +943,11 @@ http://localhost:8082/radar/Brisbane/QLD
- **Slideshow Playback**: Play, pause, and navigate through radar frames - **Slideshow Playback**: Play, pause, and navigate through radar frames
- **Frame Navigation**: Use slider, buttons, or keyboard shortcuts (arrow keys, spacebar) - **Frame Navigation**: Use slider, buttons, or keyboard shortcuts (arrow keys, spacebar)
- **Extended Timespans**: View historical data from 1 hour to 24 hours - **Extended Timespans**: View historical data with configurable time ranges (based on cache retention settings)
- **Custom Time Ranges**: Select specific start and end times for historical viewing - **Custom Time Ranges**: Select specific start and end times for historical viewing
- **Auto-Refresh**: Automatically checks for new data at configurable intervals - **Auto-Refresh**: Automatically checks for new data at configurable intervals (minimum 5 seconds, no maximum)
- **Cache Status**: Real-time display of cache validity, expiration, and update status - **Cache Status**: Real-time display of cache validity, expiration, and update status
- **Settings Panel**: Configure frame intervals, refresh rates, and playback options - **Settings Panel**: Configure frame intervals (minimum 0.1 seconds, no maximum), refresh rates, and playback options
### Keyboard Shortcuts ### Keyboard Shortcuts
@@ -531,15 +969,23 @@ async function getRadarData(suburb, state) {
if (response.status === 404) { if (response.status === 404) {
// Cache is being generated - trigger refresh and show message // Cache is being generated - trigger refresh and show message
const error = await response.json(); const error = await response.json();
if (error.refreshEndpoint) {
// Use standardized error response format
if (error.errorCode === 'CACHE_NOT_FOUND' && error.suggestions?.refreshEndpoint) {
// Trigger cache update in background // Trigger cache update in background
fetch(error.refreshEndpoint, { method: 'POST' }).catch(() => {}); fetch(error.suggestions.refreshEndpoint, { method: 'POST' }).catch(() => {});
} }
return { frames: [], message: `Cache being generated. Retry in ${error.retryAfter} seconds.` };
const retryAfter = error.suggestions?.retryAfter || 30;
return {
frames: [],
message: error.message || `Cache being generated. Retry in ${retryAfter} seconds.`
};
} }
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); const error = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
throw new Error(error.message || `HTTP ${response.status}`);
} }
return await response.json(); return await response.json();
@@ -556,7 +1002,10 @@ if (radarData.frames && radarData.frames.length > 0) {
// Or loop through all frames for animation // Or loop through all frames for animation
radarData.frames.forEach((frame, index) => { radarData.frames.forEach((frame, index) => {
console.log(`Frame ${index}: ${frame.imageUrl} (${frame.minutesAgo} min ago)`); const minutesAgo = frame.absoluteObservationTime
? Math.round((Date.now() - new Date(frame.absoluteObservationTime).getTime()) / 60000)
: null;
console.log(`Frame ${index}: ${frame.imageUrl}${minutesAgo !== null ? ` (${minutesAgo} min ago)` : ''}`);
}); });
} }
``` ```
@@ -572,6 +1021,36 @@ async function getHistoricalRadar(suburb, state, hoursBack = 3) {
`/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}` `/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}`
); );
if (response.status === 400) {
const error = await response.json();
// Use standardized error format
throw new Error(error.message || 'Invalid time range request');
}
if (response.status === 404) {
const error = await response.json();
// Check error code to determine type
if (error.errorCode === 'CACHE_NOT_FOUND') {
// Location doesn't exist - trigger cache update if endpoint provided
if (error.suggestions?.refreshEndpoint) {
fetch(error.suggestions.refreshEndpoint, { method: 'POST' }).catch(() => {});
}
throw new Error(error.message || 'Cache update triggered, please retry in a few moments.');
}
// Cache exists but no data in range
if (error.errorCode === 'TIME_RANGE_ERROR' && error.details?.availableRange) {
const range = error.details.availableRange;
const rangeMsg = range.oldest && range.newest
? ` Available data: ${new Date(range.oldest).toLocaleString()} to ${new Date(range.newest).toLocaleString()}.`
: '';
throw new Error(error.message + rangeMsg);
}
throw new Error(error.message || 'No historical data found');
}
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
@@ -648,7 +1127,29 @@ curl -X POST http://localhost:8082/api/cache/Brisbane/QLD/refresh
**Get historical data (last 3 hours):** **Get historical data (last 3 hours):**
```bash ```bash
curl "http://localhost:8082/api/radar/Brisbane/QLD/timeseries?startTime=2025-01-15T07:00:00Z" curl "http://localhost:8082/api/radar/Brisbane/QLD/timeseries?startTime=2025-01-15T07:00:00Z&endTime=2025-01-15T10:00:00Z"
```
**Get historical data with error handling:**
```bash
# Check response status
response=$(curl -s -w "\n%{http_code}" "http://localhost:8082/api/radar/Brisbane/QLD/timeseries?startTime=2025-01-15T07:00:00Z&endTime=2025-01-15T10:00:00Z")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "200" ]; then
echo "Success: $body"
elif [ "$http_code" = "400" ]; then
echo "Bad Request: $body"
elif [ "$http_code" = "404" ]; then
echo "Not Found: $body"
# Check if cache update was triggered
if echo "$body" | grep -q "updateTriggered"; then
echo "Cache update triggered, retry in a few moments"
fi
else
echo "Error ($http_code): $body"
fi
``` ```
## Troubleshooting ## Troubleshooting
@@ -676,6 +1177,25 @@ curl "http://localhost:8082/api/radar/Brisbane/QLD/timeseries?startTime=2025-01-
- **Check cache status**: Use `/api/cache/{suburb}/{state}/range` to verify cache exists - **Check cache status**: Use `/api/cache/{suburb}/{state}/range` to verify cache exists
- **Browser automation issues**: Check logs for Playwright errors - **Browser automation issues**: Check logs for Playwright errors
### Scraping Failures
If scraping fails (e.g., "Could not find element"), the BOM website structure may have changed:
- **Check debug screenshots**: Enable `DEBUG__ENABLED=true` to see what the browser sees at each step
- **Update selectors**:
- **Docker**: Mount a custom `appsettings.json` with updated selectors (see [Scraping Configuration](#scraping-configuration))
- **Local**: Edit `appsettings.json` under `Scraping:Selectors` to add new CSS selectors as fallbacks
- **Check step logs**: Each step logs its execution - look for which step failed
- **Selector fallbacks**: The system tries multiple selectors in order, so add new selectors to the existing arrays
- **Workflow steps**: Individual steps can be disabled via `Scraping:Workflows:RadarScraping:Steps:{StepName}:Enabled: false` if needed temporarily
**Quick Fix for Docker Users:**
1. Copy the default `appsettings.json` from the repository
2. Edit the selectors that are failing
3. Mount it as a volume: `-v $(pwd)/appsettings.json:/app/appsettings.json:ro`
4. Restart the container
### Playwright Resource Usage ### Playwright Resource Usage
Playwright browsers (Chromium) can consume significant CPU and memory: Playwright browsers (Chromium) can consume significant CPU and memory:
@@ -709,6 +1229,25 @@ docker run --cpus="1.0" ...
- **Increase cleanup frequency**: Lower `CACHECLEANUP__INTERVALHOURS` to clean up more often - **Increase cleanup frequency**: Lower `CACHECLEANUP__INTERVALHOURS` to clean up more often
- **Limit locations**: The service automatically manages all cached locations; reduce the number of locations being cached to lower resource usage - **Limit locations**: The service automatically manages all cached locations; reduce the number of locations being cached to lower resource usage
### Performance Monitoring
The service logs detailed performance metrics for each scraping workflow:
**Step-Level Timing**:
- Each step logs its duration and compares it to historical averages
- Example: `Step WaitForMapReady completed in 45.30s (avg: 43.76s)`
- Steps that are >50% slower than average trigger warnings: `⚠️ Step WaitForMapReady took significantly longer than average: 75.45s (avg: 50.30s, +25.15s, +50.0% slower)`
**Workflow-Level Timing**:
- Complete workflow duration is logged with step breakdown
- Example: `Workflow RadarScraping completed in 144.40s. Step breakdown: NavigateHomepage=4.96s, ClickSearchButton=2.70s, ...`
- Workflows that are >30% slower than average trigger warnings: `⚠️ Workflow RadarScraping took significantly longer than average: 189.45s (avg: 145.67s, +43.78s, +30.0% slower)`
**Metrics Storage**:
- Step and phase durations are stored in memory (last 20 samples)
- Used for performance estimation and identifying bottlenecks
- Metrics improve over time as more updates complete
## Development ## Development
### Building Locally ### Building Locally
@@ -727,7 +1266,45 @@ Enable debug mode to save intermediate screenshots during data capture:
docker run -e DEBUG__ENABLED=true -e DEBUG__WAITMS=5000 ... docker run -e DEBUG__ENABLED=true -e DEBUG__WAITMS=5000 ...
``` ```
Debug screenshots are saved in `{CACHEDIRECTORY}/debug/`. Debug screenshots are saved in `{CACHEDIRECTORY}/debug/`. Each scraping step saves a screenshot, HTML snapshot, and logs, making it easy to diagnose issues.
### Extending the Scraping System
The workflow-based architecture makes it easy to extend the scraping system:
**Adding a New Workflow**:
1. Create a new workflow class in `Services/Scraping/Workflows/` implementing `IWorkflow<TResponse>` where `TResponse` is your response type
2. Define the step sequence (can reuse existing steps)
3. Register the workflow in `WorkflowFactory`
4. Add workflow configuration to `appsettings.json`
**Adding a New Step**:
1. Create a step class inheriting from `BaseScrapingStep`
2. Implement `Name`, `Prerequisites`, `CanExecute`, and `ExecuteAsync`
3. The step will be auto-registered on startup
4. Add the step to a workflow's `StepNames` array
**Updating Selectors**:
1. Edit `appsettings.json` under `Scraping:Selectors`
2. Add new CSS selectors to the `Selectors` array (tried in order)
3. Adjust `TimeoutMs` if needed
4. No code changes required
**Updating JavaScript Templates**:
1. Edit `appsettings.json` under `Scraping:JavaScriptTemplates`
2. Update the template code as needed
3. No code changes required
**Updating Text Patterns**:
1. Edit `appsettings.json` under `Scraping:TextPatterns`
2. Update regex patterns as needed (e.g., `TimestampPattern`)
3. The `TimestampPattern` supports parsing timestamps like "Wednesday 17 Dec, 11:05 pm" when the BOM website changes format
4. No code changes required
## License ## License
+132 -36
View File
@@ -15,6 +15,8 @@ public class BomRadarService : IBomRadarService, IDisposable
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly double _cacheExpirationMinutes; private readonly double _cacheExpirationMinutes;
private readonly int _cacheManagementCheckIntervalMinutes; private readonly int _cacheManagementCheckIntervalMinutes;
private readonly int _timeSeriesWarningFolderCount;
private readonly int _estimatedUpdateDurationSeconds;
public BomRadarService( public BomRadarService(
ILogger<BomRadarService> logger, ILogger<BomRadarService> logger,
@@ -30,8 +32,48 @@ public class BomRadarService : IBomRadarService, IDisposable
_scrapingService = scrapingService; _scrapingService = scrapingService;
_debugService = debugService; _debugService = debugService;
_configuration = configuration; _configuration = configuration;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5); var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var timeSeriesWarningFolderCountConfig = configuration.GetValue<int?>("TimeSeries:WarningFolderCount");
if (!timeSeriesWarningFolderCountConfig.HasValue)
{
throw new InvalidOperationException("TimeSeries:WarningFolderCount configuration is required. Set it in appsettings.json or via TIMESERIES__WARNINGFOLDERCOUNT environment variable.");
}
_timeSeriesWarningFolderCount = timeSeriesWarningFolderCountConfig.Value;
// Calculate estimated cache update duration
_estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar);
_logger.LogInformation("Estimated cache update duration: {Seconds} seconds", _estimatedUpdateDurationSeconds);
if (_cacheExpirationMinutes <= 0)
{
throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration));
}
if (_cacheManagementCheckIntervalMinutes <= 0 || _cacheManagementCheckIntervalMinutes > 60)
{
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
}
if (_timeSeriesWarningFolderCount <= 0)
{
throw new ArgumentException("TimeSeries:WarningFolderCount must be greater than 0", nameof(configuration));
}
if (_estimatedUpdateDurationSeconds <= 0)
{
throw new ArgumentException("Calculated estimated update duration must be greater than 0. Check Screenshot:TileRenderWaitMs, Screenshot:DynamicContentWaitMs, and CachedDataTypes:Radar:FrameCount configuration values.", nameof(configuration));
}
} }
public async Task<RadarResponse?> GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default) public async Task<RadarResponse?> GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default)
@@ -56,7 +98,11 @@ public class BomRadarService : IBomRadarService, IDisposable
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null; var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
var isUpdating = _cacheService.IsLocationUpdating(locationKey); var isUpdating = _cacheService.IsLocationUpdating(locationKey);
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, _cacheManagementCheckIntervalMinutes); // Use metrics-based estimate if available, otherwise fallback
var estimatedDuration = _cacheService.GetEstimatedRemainingSeconds(locationKey);
var durationToUse = estimatedDuration > 0 ? estimatedDuration : _estimatedUpdateDurationSeconds;
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, durationToUse);
} }
public async Task<List<RadarFrame>?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default) public async Task<List<RadarFrame>?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default)
@@ -104,7 +150,19 @@ public class BomRadarService : IBomRadarService, IDisposable
status.UpdateTriggered = false; status.UpdateTriggered = false;
status.Message = "Cache update already in progress"; status.Message = "Cache update already in progress";
status.NextUpdateTime = status.CacheExpiresAt ?? DateTime.UtcNow.AddMinutes(_cacheExpirationMinutes);
// Use metrics-based estimate if available, otherwise fallback
var remainingSeconds = _cacheService.GetEstimatedRemainingSeconds(locationKey);
if (remainingSeconds > 0)
{
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds);
}
else
{
// Fallback to calculated estimate
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(_estimatedUpdateDurationSeconds);
}
return status; return status;
} }
@@ -127,6 +185,9 @@ public class BomRadarService : IBomRadarService, IDisposable
if (needsUpdate) if (needsUpdate)
{ {
// Trigger async update (fire and forget) // Trigger async update (fire and forget)
// Do not pass the caller's cancellationToken to Task.Run: for HTTP that token is tied to
// RequestAborted and often fires as soon as the response is sent, so the update never starts.
// FetchAndCacheScreenshotAsync already uses CancellationToken.None for the scrape work.
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
@@ -137,21 +198,23 @@ public class BomRadarService : IBomRadarService, IDisposable
{ {
_logger.LogError(ex, "Error during background cache update for {Suburb}, {State}", suburb, state); _logger.LogError(ex, "Error during background cache update for {Suburb}, {State}", suburb, state);
} }
}, cancellationToken); });
status.UpdateTriggered = true; status.UpdateTriggered = true;
status.Message = status.CacheExists status.Message = status.CacheExists
? "Cache is stale, update triggered" ? "Cache is stale, update triggered"
: "No cache exists, update triggered"; : "No cache exists, update triggered";
// Calculate next update time // Try metrics-based estimate first, then fallback to calculated
if (status.CacheExpiresAt.HasValue && status.CacheExpiresAt.Value > DateTime.UtcNow) var remainingSeconds = _cacheService.GetEstimatedRemainingSeconds(locationKey);
if (remainingSeconds > 0)
{ {
status.NextUpdateTime = status.CacheExpiresAt.Value; status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds);
} }
else else
{ {
status.NextUpdateTime = DateTime.UtcNow.AddMinutes(_cacheExpirationMinutes); // Fallback to calculated estimate
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(_estimatedUpdateDurationSeconds);
} }
} }
else else
@@ -183,7 +246,12 @@ public class BomRadarService : IBomRadarService, IDisposable
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata); var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata);
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null; var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true, _cacheManagementCheckIntervalMinutes);
// Use metrics-based estimate if available, otherwise fallback
var estimatedDuration = _cacheService.GetEstimatedRemainingSeconds(locationKey);
var durationToUse = estimatedDuration > 0 ? estimatedDuration : _estimatedUpdateDurationSeconds;
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true, durationToUse);
} }
else else
{ {
@@ -200,7 +268,7 @@ public class BomRadarService : IBomRadarService, IDisposable
_logger.LogInformation("Returning valid cached screenshots for {Suburb}, {State} (no semaphore needed)", suburb, state); _logger.LogInformation("Returning valid cached screenshots for {Suburb}, {State} (no semaphore needed)", suburb, state);
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
var cacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); var cacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes); return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, _cacheManagementCheckIntervalMinutes, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false, _estimatedUpdateDurationSeconds);
} }
else else
{ {
@@ -250,7 +318,7 @@ public class BomRadarService : IBomRadarService, IDisposable
_logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots"); _logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
var recheckCacheExpiresAt = recheckCachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); var recheckCacheExpiresAt = recheckCachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes); return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, _cacheManagementCheckIntervalMinutes, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false);
} }
// Create debug folder only now // Create debug folder only now
@@ -275,11 +343,25 @@ public class BomRadarService : IBomRadarService, IDisposable
// Remove from active tracking once complete // Remove from active tracking once complete
_cacheService.ClearActiveCacheFolder(locationKey); _cacheService.ClearActiveCacheFolder(locationKey);
// Update result with cache state // Update result with cache state - rebuild response to get correct NextUpdateTime calculation
result.IsUpdating = false; var cacheExpiresAt = result.ObservationTime.AddMinutes(_cacheExpirationMinutes);
result.CacheIsValid = true; var metadata = new LastUpdatedInfo
result.CacheExpiresAt = result.ObservationTime.AddMinutes(_cacheExpirationMinutes); {
result.NextUpdateTime = result.CacheExpiresAt; ObservationTime = result.ObservationTime,
ForecastTime = result.ForecastTime,
WeatherStation = result.WeatherStation,
Distance = result.Distance
};
result = ResponseBuilder.CreateRadarResponse(
cacheFolderPath: newCacheFolderPath,
frames: result.Frames,
metadata: metadata,
suburb: suburb,
state: state,
cacheIsValid: true,
cacheExpiresAt: cacheExpiresAt,
isUpdating: false,
cacheManagementCheckIntervalMinutes: _cacheManagementCheckIntervalMinutes);
return result; return result;
} }
@@ -327,7 +409,16 @@ public class BomRadarService : IBomRadarService, IDisposable
} }
} }
semaphore.Release(); // Safely release semaphore - it may be disposed during application shutdown
try
{
semaphore.Release();
}
catch (ObjectDisposedException)
{
// Semaphore was disposed during shutdown, ignore
_logger.LogDebug("Semaphore was disposed, skipping release");
}
} }
} }
@@ -406,6 +497,16 @@ public class BomRadarService : IBomRadarService, IDisposable
filteredFolders = filteredFolders.Where(f => f.CacheTimestamp <= endTime.Value); filteredFolders = filteredFolders.Where(f => f.CacheTimestamp <= endTime.Value);
} }
var filteredFoldersList = filteredFolders.ToList();
// Warn if processing a large number of folders (configurable threshold)
if (filteredFoldersList.Count > _timeSeriesWarningFolderCount)
{
_logger.LogWarning(
"Processing large number of cache folders ({Count}) for time series request. Suburb: {Suburb}, State: {State}, StartTime: {StartTime}, EndTime: {EndTime}",
filteredFoldersList.Count, suburb, state, startTime, endTime);
}
var result = new List<RadarCacheFolderFrames>(); var result = new List<RadarCacheFolderFrames>();
var encodedSuburb = Uri.EscapeDataString(suburb); var encodedSuburb = Uri.EscapeDataString(suburb);
var encodedState = Uri.EscapeDataString(state); var encodedState = Uri.EscapeDataString(state);
@@ -414,7 +515,7 @@ public class BomRadarService : IBomRadarService, IDisposable
// Use a HashSet to track unique absolute observation times we've already seen // Use a HashSet to track unique absolute observation times we've already seen
var seenAbsoluteTimes = new HashSet<DateTime>(); var seenAbsoluteTimes = new HashSet<DateTime>();
foreach (var folderInfo in filteredFolders.OrderByDescending(f => f.CacheTimestamp)) foreach (var folderInfo in filteredFoldersList.OrderByDescending(f => f.CacheTimestamp))
{ {
// Load frames from this folder's radar subfolder // Load frames from this folder's radar subfolder
var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync( var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync(
@@ -430,32 +531,27 @@ public class BomRadarService : IBomRadarService, IDisposable
if (radarFrames.Count > 0) if (radarFrames.Count > 0)
{ {
// Generate URLs and calculate absolute observation times for each frame // Generate URLs and filter frames by unique absolute observation times
var uniqueFrames = new List<RadarFrame>(); var uniqueFrames = new List<RadarFrame>();
foreach (var frame in radarFrames) foreach (var frame in radarFrames)
{ {
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}"; frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
// Calculate absolute observation time for this frame // Use absolute observation time directly (already set during capture)
// minutesAgo is relative to the cache folder's observation time // Only include frames with unique absolute observation times
// Validate that ObservationTime is reasonable (not default/min value) // Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0) if (frame.AbsoluteObservationTime.HasValue &&
frame.AbsoluteObservationTime.Value > DateTime.MinValue.AddYears(1) &&
!seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
{ {
frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo); seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
uniqueFrames.Add(frame);
// Only include frames with unique absolute observation times
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
if (frame.AbsoluteObservationTime.HasValue && !seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
{
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
uniqueFrames.Add(frame);
}
} }
else else if (!frame.AbsoluteObservationTime.HasValue)
{ {
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Invalid ObservationTime ({ObservationTime}) or MinutesAgo ({MinutesAgo})", _logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Missing AbsoluteObservationTime",
frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo); frame.FrameIndex, folderInfo.FolderName);
} }
} }
+9 -1
View File
@@ -15,7 +15,15 @@ public class BrowserService : IBrowserService
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService) public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
{ {
_logger = logger; _logger = logger;
_timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane"; // Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
var timezone = configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
{
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
}
_timezone = timezone;
_debugService = debugService; _debugService = debugService;
} }
+16 -3
View File
@@ -1,9 +1,10 @@
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Utilities; using BomLocalService.Utilities;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
namespace BomLocalService.Services; namespace BomLocalService.Services;
public class CacheCleanupService : BackgroundService public class CacheCleanupService : BackgroundService, IHostedServiceRegistration
{ {
private readonly ILogger<CacheCleanupService> _logger; private readonly ILogger<CacheCleanupService> _logger;
private readonly string _cacheDirectory; private readonly string _cacheDirectory;
@@ -14,8 +15,20 @@ public class CacheCleanupService : BackgroundService
{ {
_logger = logger; _logger = logger;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); _cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_retentionHours = configuration.GetValue<int>("CacheRetentionHours", 24);
var cleanupIntervalHours = configuration.GetValue<int>("CacheCleanup:IntervalHours", 1); var retentionHoursConfig = configuration.GetValue<int?>("CacheRetentionHours");
if (!retentionHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
}
_retentionHours = retentionHoursConfig.Value;
var cleanupIntervalHoursConfig = configuration.GetValue<int?>("CacheCleanup:IntervalHours");
if (!cleanupIntervalHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheCleanup:IntervalHours configuration is required. Set it in appsettings.json or via CACHECLEANUP__INTERVALHOURS environment variable.");
}
var cleanupIntervalHours = cleanupIntervalHoursConfig.Value;
_cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours); _cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours);
} }
+66 -48
View File
@@ -1,16 +1,19 @@
using BomLocalService.Services.Interfaces; using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Utilities; using BomLocalService.Utilities;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
namespace BomLocalService.Services; namespace BomLocalService.Services;
public class CacheManagementService : BackgroundService public class CacheManagementService : BackgroundService, IHostedServiceRegistration
{ {
private readonly ILogger<CacheManagementService> _logger; private readonly ILogger<CacheManagementService> _logger;
private readonly IBomRadarService _bomRadarService; private readonly IBomRadarService _bomRadarService;
private readonly IBrowserService _browserService; private readonly IBrowserService _browserService;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly TimeSpan _checkInterval; private readonly TimeSpan _checkInterval;
private readonly TimeSpan _locationStaggerInterval;
private readonly TimeSpan _initialDelayInterval;
private readonly HashSet<string> _activeUpdates = new(); private readonly HashSet<string> _activeUpdates = new();
private readonly object _lock = new(); private readonly object _lock = new();
@@ -24,8 +27,34 @@ public class CacheManagementService : BackgroundService
_bomRadarService = bomRadarService; _bomRadarService = bomRadarService;
_browserService = browserService; _browserService = browserService;
_configuration = configuration; _configuration = configuration;
var checkIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
var checkIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!checkIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var checkIntervalMinutes = checkIntervalMinutesConfig.Value;
if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60)
{
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
}
_checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes); _checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes);
// LocationStaggerSeconds is used for delays between processing locations (both initial and periodic)
var locationStaggerSecondsConfig = configuration.GetValue<int?>("CacheManagement:LocationStaggerSeconds");
if (!locationStaggerSecondsConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:LocationStaggerSeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS environment variable.");
}
_locationStaggerInterval = TimeSpan.FromSeconds(locationStaggerSecondsConfig.Value);
var initialDelaySecondsConfig = configuration.GetValue<int?>("CacheManagement:InitialDelaySeconds");
if (!initialDelaySecondsConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:InitialDelaySeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__INITIALDELAYSECONDS environment variable.");
}
_initialDelayInterval = TimeSpan.FromSeconds(initialDelaySecondsConfig.Value);
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -33,8 +62,7 @@ public class CacheManagementService : BackgroundService
_logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval); _logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval);
// Wait a bit for the service to fully initialize // Wait a bit for the service to fully initialize
var initialDelaySeconds = _configuration.GetValue<int>("CacheManagement:InitialDelaySeconds", 10); await Task.Delay(_initialDelayInterval, stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
// Pre-warm the browser before starting cache updates // Pre-warm the browser before starting cache updates
_logger.LogInformation("Pre-warming browser before cache updates"); _logger.LogInformation("Pre-warming browser before cache updates");
@@ -52,28 +80,7 @@ public class CacheManagementService : BackgroundService
// Initial cache update on startup // Initial cache update on startup
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count); _logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
int initialUpdatesTriggered = 0; var (initialUpdatesTriggered, initialCachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
int initialCachesValid = 0;
foreach (var (suburb, state) in locationsToManage)
{
if (stoppingToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
if (triggered)
{
initialUpdatesTriggered++;
}
else if (isValid)
{
initialCachesValid++;
}
// Stagger updates to avoid overwhelming the system
var updateStaggerSeconds = _configuration.GetValue<int>("CacheManagement:UpdateStaggerSeconds", 2);
await Task.Delay(TimeSpan.FromSeconds(updateStaggerSeconds), stoppingToken);
}
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid", _logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
initialUpdatesTriggered, initialCachesValid); initialUpdatesTriggered, initialCachesValid);
@@ -89,28 +96,7 @@ public class CacheManagementService : BackgroundService
locationsToManage = GetLocationsToManage(); locationsToManage = GetLocationsToManage();
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count); _logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
int updatesTriggered = 0; var (updatesTriggered, cachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
int cachesValid = 0;
foreach (var (suburb, state) in locationsToManage)
{
if (stoppingToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
if (triggered)
{
updatesTriggered++;
}
else if (isValid)
{
cachesValid++;
}
// Small delay between locations
var locationStaggerSeconds = _configuration.GetValue<int>("CacheManagement:LocationStaggerSeconds", 1);
await Task.Delay(TimeSpan.FromSeconds(locationStaggerSeconds), stoppingToken);
}
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid", _logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
updatesTriggered, cachesValid); updatesTriggered, cachesValid);
@@ -122,6 +108,38 @@ public class CacheManagementService : BackgroundService
} }
} }
/// <summary>
/// Processes a list of locations, checking and updating cache as needed
/// </summary>
private async Task<(int updatesTriggered, int cachesValid)> ProcessLocationsAsync(
List<(string suburb, string state)> locations,
CancellationToken cancellationToken)
{
int updatesTriggered = 0;
int cachesValid = 0;
foreach (var (suburb, state) in locations)
{
if (cancellationToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, cancellationToken);
if (triggered)
{
updatesTriggered++;
}
else if (isValid)
{
cachesValid++;
}
// Stagger processing to avoid overwhelming the system
await Task.Delay(_locationStaggerInterval, cancellationToken);
}
return (updatesTriggered, cachesValid);
}
private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken) private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken)
{ {
var locationKey = LocationHelper.GetLocationKey(suburb, state); var locationKey = LocationHelper.GetLocationKey(suburb, state);
+312 -19
View File
@@ -13,13 +13,32 @@ public class CacheService : ICacheService
private readonly double _cacheExpirationMinutes; private readonly double _cacheExpirationMinutes;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
// Progress tracking for cache updates
private readonly ConcurrentDictionary<string, (DateTime startTime, CacheUpdatePhase phase, int? currentFrame, int? totalFrames)> _updateProgress = new();
private readonly ConcurrentQueue<double> _recentTotalDurations = new(); // Overall durations in seconds
private readonly ConcurrentDictionary<CacheUpdatePhase, ConcurrentQueue<double>> _phaseDurations = new(); // Phase -> durations
private readonly ConcurrentDictionary<string, ConcurrentQueue<double>> _stepDurations = new(); // Step name -> durations
private readonly object _metricsLock = new();
private const int MaxSamples = 20;
public CacheService(ILogger<CacheService> logger, IConfiguration configuration) public CacheService(ILogger<CacheService> logger, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); _cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
if (_cacheExpirationMinutes <= 0)
{
throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration));
}
// Ensure cache directory exists // Ensure cache directory exists
Directory.CreateDirectory(_cacheDirectory); Directory.CreateDirectory(_cacheDirectory);
@@ -90,7 +109,10 @@ public class CacheService : ICacheService
string state, string state,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken); // Exclude active cache folder (currently being written to) to avoid reading incomplete data
var locationKey = LocationHelper.GetLocationKey(suburb, state);
var excludeFolder = GetActiveCacheFolder(locationKey);
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
{ {
@@ -116,7 +138,10 @@ public class CacheService : ICacheService
return null; return null;
} }
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken); // Exclude active cache folder (currently being written to) to avoid reading incomplete data
var locationKey = LocationHelper.GetLocationKey(suburb, state);
var excludeFolder = GetActiveCacheFolder(locationKey);
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
if (string.IsNullOrEmpty(cacheFolderPath)) if (string.IsNullOrEmpty(cacheFolderPath))
{ {
@@ -129,18 +154,18 @@ public class CacheService : ICacheService
return null; return null;
} }
// Load frame metadata to get accurate minutesAgo // Load frame metadata to get observation time
var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken); var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken);
var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex); var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
var minutesAgo = frameMetadata != null var observationTime = frameMetadata?.ObservationTime > DateTime.MinValue.AddYears(1)
? frameMetadata.MinutesAgo ? frameMetadata.ObservationTime
: 40 - (frameIndex * 5); : (DateTime?)null;
return new RadarFrame return new RadarFrame
{ {
FrameIndex = frameIndex, FrameIndex = frameIndex,
ImagePath = framePath, ImagePath = framePath,
MinutesAgo = minutesAgo AbsoluteObservationTime = observationTime
}; };
} }
@@ -175,7 +200,7 @@ public class CacheService : ICacheService
var framesMetadata = frames.Select(f => new FrameMetadata var framesMetadata = frames.Select(f => new FrameMetadata
{ {
FrameIndex = f.FrameIndex, FrameIndex = f.FrameIndex,
MinutesAgo = f.MinutesAgo ObservationTime = f.AbsoluteObservationTime ?? throw new InvalidOperationException($"Frame {f.FrameIndex} missing AbsoluteObservationTime")
}).ToList(); }).ToList();
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType); var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
@@ -319,6 +344,10 @@ public class CacheService : ICacheService
string state, string state,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Exclude active cache folder (currently being written to) to avoid reading incomplete data
var locationKey = LocationHelper.GetLocationKey(suburb, state);
var excludeFolder = GetActiveCacheFolder(locationKey);
var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state); var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state);
var folders = Directory.GetDirectories(_cacheDirectory, pattern) var folders = Directory.GetDirectories(_cacheDirectory, pattern)
.Select(f => .Select(f =>
@@ -339,6 +368,13 @@ public class CacheService : ICacheService
if (!Directory.Exists(folder.Folder)) if (!Directory.Exists(folder.Folder))
continue; continue;
// Skip the folder if it's being excluded (currently being written to)
if (!string.IsNullOrEmpty(excludeFolder) && Path.GetFullPath(folder.Folder).Equals(Path.GetFullPath(excludeFolder), StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Skipping excluded cache folder (being written to): {Folder}", folder.Folder);
continue;
}
var metadata = await LoadMetadataAsync(folder.Folder, cancellationToken); var metadata = await LoadMetadataAsync(folder.Folder, cancellationToken);
if (metadata == null) if (metadata == null)
continue; continue;
@@ -388,7 +424,8 @@ public class CacheService : ICacheService
// Load frame metadata from frames.json if available // Load frame metadata from frames.json if available
var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken); var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken);
var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo); var metadataDict = framesMetadata.Where(f => f.ObservationTime > DateTime.MinValue.AddYears(1))
.ToDictionary(f => f.FrameIndex, f => f.ObservationTime);
// Load frames from data type subfolder // Load frames from data type subfolder
for (int i = 0; i < frameCount; i++) for (int i = 0; i < frameCount; i++)
@@ -398,15 +435,13 @@ public class CacheService : ICacheService
{ {
if (dataType == CachedDataType.Radar) if (dataType == CachedDataType.Radar)
{ {
var minutesAgo = metadataDict.ContainsKey(i) var observationTime = metadataDict.ContainsKey(i) ? metadataDict[i] : (DateTime?)null;
? metadataDict[i]
: 40 - (i * 5);
frames.Add(new RadarFrame frames.Add(new RadarFrame
{ {
FrameIndex = i, FrameIndex = i,
ImagePath = framePath, ImagePath = framePath,
MinutesAgo = minutesAgo AbsoluteObservationTime = observationTime
}); });
} }
else else
@@ -497,6 +532,7 @@ public class CacheService : ICacheService
public void SetActiveCacheFolder(string locationKey, string cacheFolderPath) public void SetActiveCacheFolder(string locationKey, string cacheFolderPath)
{ {
_activeCacheFolders[locationKey] = cacheFolderPath; _activeCacheFolders[locationKey] = cacheFolderPath;
_updateProgress[locationKey] = (DateTime.UtcNow, CacheUpdatePhase.Initializing, null, null);
_logger.LogDebug("Tracking active cache folder: {Folder} for location: {Location}", cacheFolderPath, locationKey); _logger.LogDebug("Tracking active cache folder: {Folder} for location: {Location}", cacheFolderPath, locationKey);
} }
@@ -504,10 +540,259 @@ public class CacheService : ICacheService
{ {
if (_activeCacheFolders.TryRemove(locationKey, out var folder)) if (_activeCacheFolders.TryRemove(locationKey, out var folder))
{ {
RecordUpdateComplete(locationKey);
_updateProgress.TryRemove(locationKey, out _);
_logger.LogDebug("Cleared active cache folder tracking: {Folder} for location: {Location}", folder, locationKey); _logger.LogDebug("Cleared active cache folder tracking: {Folder} for location: {Location}", folder, locationKey);
} }
} }
/// <summary>
/// Records progress update for a cache update operation.
/// </summary>
public void RecordUpdateProgress(string locationKey, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null)
{
if (_updateProgress.TryGetValue(locationKey, out var existing))
{
// Record duration for previous phase if it changed
var previousPhase = existing.phase;
if (previousPhase != phase)
{
var phaseDuration = (DateTime.UtcNow - existing.startTime).TotalSeconds;
lock (_metricsLock)
{
var durations = _phaseDurations.GetOrAdd(previousPhase, _ => new ConcurrentQueue<double>());
durations.Enqueue(phaseDuration);
while (durations.Count > MaxSamples)
{
durations.TryDequeue(out _);
}
}
}
}
// Update progress
var startTime = _updateProgress.TryGetValue(locationKey, out var current) ? current.startTime : DateTime.UtcNow;
_updateProgress[locationKey] = (startTime, phase, currentFrame, totalFrames);
}
/// <summary>
/// Records completion of a cache update and stores metrics.
/// </summary>
private void RecordUpdateComplete(string locationKey)
{
if (_updateProgress.TryRemove(locationKey, out var progress))
{
var totalDuration = (DateTime.UtcNow - progress.startTime).TotalSeconds;
lock (_metricsLock)
{
_recentTotalDurations.Enqueue(totalDuration);
while (_recentTotalDurations.Count > MaxSamples)
{
_recentTotalDurations.TryDequeue(out _);
}
}
_logger.LogInformation("Cache update completed in {Duration:F1} seconds for {Location}", totalDuration, locationKey);
}
}
/// <summary>
/// Gets the estimated remaining seconds for an in-progress cache update.
/// Returns 0 if not updating or no metrics available.
/// </summary>
public int GetEstimatedRemainingSeconds(string locationKey)
{
if (!_updateProgress.TryGetValue(locationKey, out var progress))
{
return 0; // Not updating
}
var elapsed = (DateTime.UtcNow - progress.startTime).TotalSeconds;
// If we have historical data, use it
var avgTotal = GetAverageTotalDuration();
if (avgTotal > 0)
{
// Estimate based on phase and progress
double estimatedRemaining = 0;
switch (progress.phase)
{
case CacheUpdatePhase.Initializing:
// Estimate: avg total - elapsed (with some buffer)
estimatedRemaining = Math.Max(0, avgTotal * 1.1 - elapsed);
break;
case CacheUpdatePhase.CapturingFrames:
if (progress.currentFrame.HasValue && progress.totalFrames.HasValue)
{
// We know exactly where we are: frame X of Y
var framesRemaining = progress.totalFrames.Value - progress.currentFrame.Value - 1;
var avgFrameDuration = GetAverageFrameDuration();
if (avgFrameDuration > 0)
{
// Time for remaining frames + saving phase
estimatedRemaining = (framesRemaining * avgFrameDuration) + GetAveragePhaseDuration(CacheUpdatePhase.Saving);
}
else
{
// Fallback: estimate based on progress through total
var progressFraction = (progress.currentFrame.Value + 1.0) / progress.totalFrames.Value;
estimatedRemaining = Math.Max(0, (avgTotal / progressFraction) - elapsed);
}
}
else
{
// No frame info - estimate based on elapsed time
estimatedRemaining = Math.Max(0, avgTotal - elapsed);
}
break;
case CacheUpdatePhase.Saving:
// Almost done - just saving phase remaining
estimatedRemaining = GetAveragePhaseDuration(CacheUpdatePhase.Saving);
if (estimatedRemaining == 0)
{
estimatedRemaining = 5; // Default 5 seconds for saving
}
break;
}
return (int)Math.Ceiling(Math.Max(0, estimatedRemaining));
}
// No historical data - return 0 to signal fallback
return 0;
}
/// <summary>
/// Gets the average total duration of cache updates from recent metrics.
/// </summary>
public double GetAverageTotalDuration()
{
lock (_metricsLock)
{
if (_recentTotalDurations.Count == 0) return 0;
var durations = _recentTotalDurations.ToArray();
Array.Sort(durations);
// Use median for robustness
var median = durations.Length % 2 == 0
? (durations[durations.Length / 2 - 1] + durations[durations.Length / 2]) / 2.0
: durations[durations.Length / 2];
return median;
}
}
/// <summary>
/// Gets the average duration per frame based on historical CapturingFrames phase data.
/// </summary>
private double GetAverageFrameDuration()
{
// Estimate frame duration from CapturingFrames phase duration / frame count
var avgCapturingDuration = GetAveragePhaseDuration(CacheUpdatePhase.CapturingFrames);
var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar);
return avgCapturingDuration > 0 && frameCount > 0 ? avgCapturingDuration / frameCount : 0;
}
/// <summary>
/// Gets the average duration for a specific phase from historical data.
/// </summary>
private double GetAveragePhaseDuration(CacheUpdatePhase phase)
{
lock (_metricsLock)
{
if (!_phaseDurations.TryGetValue(phase, out var durations) || durations.Count == 0)
{
return 0;
}
var durationsArray = durations.ToArray();
return durationsArray.Average();
}
}
/// <summary>
/// Records step completion timing for metrics tracking.
/// </summary>
public void RecordStepCompletion(string stepName, double durationSeconds)
{
lock (_metricsLock)
{
var durations = _stepDurations.GetOrAdd(stepName, _ => new ConcurrentQueue<double>());
durations.Enqueue(durationSeconds);
while (durations.Count > MaxSamples)
{
durations.TryDequeue(out _);
}
}
}
/// <summary>
/// Gets the average duration for a specific step from historical data.
/// </summary>
public double GetAverageStepDuration(string stepName)
{
lock (_metricsLock)
{
if (!_stepDurations.TryGetValue(stepName, out var durations) || durations.Count == 0)
{
return 0;
}
var durationsArray = durations.ToArray();
return durationsArray.Average();
}
}
/// <summary>
/// Gets step performance metrics for debugging/logging.
/// </summary>
public Dictionary<string, double> GetStepMetrics()
{
lock (_metricsLock)
{
var metrics = new Dictionary<string, double>();
foreach (var kvp in _stepDurations)
{
if (kvp.Value.Count > 0)
{
var durations = kvp.Value.ToArray();
metrics[kvp.Key] = durations.Average();
}
}
return metrics;
}
}
/// <summary>
/// Gets the locationKey from a cacheFolderPath by parsing the folder name.
/// </summary>
private string? GetLocationKeyFromCacheFolder(string cacheFolderPath)
{
var folderName = Path.GetFileName(cacheFolderPath);
var location = LocationHelper.ParseLocationFromFilename(folderName);
if (location.HasValue)
{
return LocationHelper.GetLocationKey(location.Value.suburb, location.Value.state);
}
return null;
}
/// <summary>
/// Records progress update for a cache update operation using cacheFolderPath.
/// This is useful when locationKey is not directly available (e.g., in ScrapingService).
/// </summary>
public void RecordUpdateProgressByFolder(string cacheFolderPath, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null)
{
var locationKey = GetLocationKeyFromCacheFolder(cacheFolderPath);
if (locationKey != null)
{
RecordUpdateProgress(locationKey, phase, currentFrame, totalFrames);
}
}
public string CreateCacheFolder(string suburb, string state, string timestamp) public string CreateCacheFolder(string suburb, string state, string timestamp)
{ {
var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp); var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp);
@@ -605,8 +890,18 @@ public class CacheService : ICacheService
if (isUpdating) if (isUpdating)
{ {
status.Message = "Cache update already in progress"; status.Message = "Cache update already in progress";
// Update in progress - estimate completion in ~2 minutes // Try metrics-based estimate first
status.NextUpdateTime = DateTime.UtcNow.AddMinutes(2); var remainingSeconds = GetEstimatedRemainingSeconds(locationKey);
if (remainingSeconds > 0)
{
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds);
}
else
{
// Fallback to calculated estimate
var estimatedDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(_configuration, dataType);
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(estimatedDurationSeconds);
}
} }
else if (status.CacheIsValid && status.CacheExpiresAt.HasValue) else if (status.CacheIsValid && status.CacheExpiresAt.HasValue)
{ {
@@ -618,9 +913,7 @@ public class CacheService : ICacheService
{ {
// Cache is invalid/missing - next update would be after background service check // Cache is invalid/missing - next update would be after background service check
status.Message = status.CacheExists ? "Cache is stale" : "No cache exists"; status.Message = status.CacheExists ? "Cache is stale" : "No cache exists";
var now = DateTime.UtcNow; status.NextUpdateTime = ResponseBuilder.CalculateNextServiceCheck(cacheManagementCheckIntervalMinutes);
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes);
status.NextUpdateTime = now.AddMinutes(minutesUntilNextCheck);
} }
return status; return status;
+22 -17
View File
@@ -14,20 +14,17 @@ namespace BomLocalService.Services;
public DebugService(ILogger<DebugService> logger, IConfiguration configuration) public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_waitMs = configuration.GetValue<int>("Debug:WaitMs", 2000);
var waitMsConfig = configuration.GetValue<int?>("Debug:WaitMs");
if (!waitMsConfig.HasValue)
{
throw new InvalidOperationException("Debug:WaitMs configuration is required. Set it in appsettings.json or via DEBUG__WAITMS environment variable.");
}
_waitMs = waitMsConfig.Value;
// Check all possible ways the config might be set // Get debug enabled from configuration (default from appsettings.json, can be overridden via DEBUG__ENABLED environment variable)
var debugEnabledEnv = Environment.GetEnvironmentVariable("DEBUG__ENABLED"); // Note: bool defaults to false if not found, which is acceptable for Debug:Enabled
var debugEnabledConfig = configuration.GetValue<bool>("Debug:Enabled", false); _enabled = configuration.GetValue<bool>("Debug:Enabled", false);
var debugEnabledFromConfig = configuration["Debug:Enabled"];
_logger.LogInformation("Debug configuration check - Env var DEBUG__ENABLED: {EnvVar}, Config Debug:Enabled: {ConfigValue}, Config string: {ConfigString}",
debugEnabledEnv ?? "null", debugEnabledConfig, debugEnabledFromConfig ?? "null");
// Try environment variable first, then config
_enabled = !string.IsNullOrEmpty(debugEnabledEnv)
? bool.TryParse(debugEnabledEnv, out var envBool) && envBool
: debugEnabledConfig;
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_debugDirectory = Path.Combine(cacheDirectory, "debug"); _debugDirectory = Path.Combine(cacheDirectory, "debug");
@@ -90,18 +87,22 @@ namespace BomLocalService.Services;
await File.WriteAllTextAsync(htmlPath, htmlContent, cancellationToken); await File.WriteAllTextAsync(htmlPath, htmlContent, cancellationToken);
// Save console messages if provided // Save console messages if provided
// Create a snapshot to avoid collection modification during enumeration
if (consoleMessages != null && consoleMessages.Count > 0) if (consoleMessages != null && consoleMessages.Count > 0)
{ {
var consolePath = Path.Combine(stepFolder, "console.log"); var consolePath = Path.Combine(stepFolder, "console.log");
var consoleText = string.Join("\n", consoleMessages.Select(msg => $"[{msg.timestamp:HH:mm:ss.fff}] [{msg.type}] {msg.text}")); var consoleSnapshot = consoleMessages.ToList(); // Create snapshot
var consoleText = string.Join("\n", consoleSnapshot.Select(msg => $"[{msg.timestamp:HH:mm:ss.fff}] [{msg.type}] {msg.text}"));
await File.WriteAllTextAsync(consolePath, consoleText, cancellationToken); await File.WriteAllTextAsync(consolePath, consoleText, cancellationToken);
} }
// Save network requests summary if provided // Save network requests summary if provided
// Create a snapshot to avoid collection modification during enumeration
if (networkRequests != null && networkRequests.Count > 0) if (networkRequests != null && networkRequests.Count > 0)
{ {
var networkPath = Path.Combine(stepFolder, "network.log"); var networkPath = Path.Combine(stepFolder, "network.log");
var networkText = string.Join("\n", networkRequests.Select(req => var networkSnapshot = networkRequests.ToList(); // Create snapshot
var networkText = string.Join("\n", networkSnapshot.Select(req =>
$"[{req.timestamp:HH:mm:ss.fff}] {req.method} {req.url} -> {req.status?.ToString() ?? "pending"} ({req.resourceType})" $"[{req.timestamp:HH:mm:ss.fff}] {req.method} {req.url} -> {req.status?.ToString() ?? "pending"} ({req.resourceType})"
)); ));
await File.WriteAllTextAsync(networkPath, networkText, cancellationToken); await File.WriteAllTextAsync(networkPath, networkText, cancellationToken);
@@ -148,18 +149,22 @@ namespace BomLocalService.Services;
} }
// Save console messages if provided // Save console messages if provided
// Create a snapshot to avoid collection modification during enumeration
if (consoleMessages != null && consoleMessages.Count > 0) if (consoleMessages != null && consoleMessages.Count > 0)
{ {
var consolePath = Path.Combine(errorFolder, "console.log"); var consolePath = Path.Combine(errorFolder, "console.log");
var consoleText = string.Join("\n", consoleMessages.Select(msg => $"[{msg.timestamp:HH:mm:ss.fff}] [{msg.type}] {msg.text}")); var consoleSnapshot = consoleMessages.ToList(); // Create snapshot
var consoleText = string.Join("\n", consoleSnapshot.Select(msg => $"[{msg.timestamp:HH:mm:ss.fff}] [{msg.type}] {msg.text}"));
await File.WriteAllTextAsync(consolePath, consoleText, cancellationToken); await File.WriteAllTextAsync(consolePath, consoleText, cancellationToken);
} }
// Save network requests summary if provided // Save network requests summary if provided
// Create a snapshot to avoid collection modification during enumeration
if (networkRequests != null && networkRequests.Count > 0) if (networkRequests != null && networkRequests.Count > 0)
{ {
var networkPath = Path.Combine(errorFolder, "network.log"); var networkPath = Path.Combine(errorFolder, "network.log");
var networkText = string.Join("\n", networkRequests.Select(req => var networkSnapshot = networkRequests.ToList(); // Create snapshot
var networkText = string.Join("\n", networkSnapshot.Select(req =>
$"[{req.timestamp:HH:mm:ss.fff}] {req.method} {req.url} -> {req.status?.ToString() ?? "pending"} ({req.resourceType})" $"[{req.timestamp:HH:mm:ss.fff}] {req.method} {req.url} -> {req.status?.ToString() ?? "pending"} ({req.resourceType})"
)); ));
await File.WriteAllTextAsync(networkPath, networkText, cancellationToken); await File.WriteAllTextAsync(networkPath, networkText, cancellationToken);
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models; using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Interfaces; namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Main service interface for BOM radar screenshot operations. /// Main service interface for BOM radar screenshot operations.
/// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations. /// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations.
/// </summary> /// </summary>
public interface IBomRadarService public interface IBomRadarService : ISingletonService
{ {
/// <summary> /// <summary>
/// Gets cached radar data for a location. /// Gets cached radar data for a location.
+2 -1
View File
@@ -1,3 +1,4 @@
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright; using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces; namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for managing Playwright browser instances and automation. /// Service interface for managing Playwright browser instances and automation.
/// Handles browser lifecycle, context creation, and anti-detection measures for web scraping. /// Handles browser lifecycle, context creation, and anti-detection measures for web scraping.
/// </summary> /// </summary>
public interface IBrowserService : IDisposable public interface IBrowserService : ISingletonService, IDisposable
{ {
/// <summary> /// <summary>
/// Creates a new browser context with proper configuration for BOM website scraping. /// Creates a new browser context with proper configuration for BOM website scraping.
+48 -2
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models; using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Interfaces; namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for managing cached radar screenshot files and metadata. /// Service interface for managing cached radar screenshot files and metadata.
/// Handles file system operations for storing and retrieving cached BOM radar screenshots. /// Handles file system operations for storing and retrieving cached BOM radar screenshots.
/// </summary> /// </summary>
public interface ICacheService public interface ICacheService : ISingletonService
{ {
/// <summary> /// <summary>
/// Gets the cached screenshot folder path and associated metadata for a location and data type. /// Gets the cached screenshot folder path and associated metadata for a location and data type.
@@ -90,7 +91,7 @@ public interface ICacheService
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default); Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Saves frame metadata (frame index and minutes ago) to a frames.json file in the data type subfolder. /// Saves frame metadata (frame index and observation time) to a frames.json file in the data type subfolder.
/// </summary> /// </summary>
/// <param name="cacheFolderPath">Full path to the cache folder</param> /// <param name="cacheFolderPath">Full path to the cache folder</param>
/// <param name="dataType">The type of cached data</param> /// <param name="dataType">The type of cached data</param>
@@ -195,5 +196,50 @@ public interface ICacheService
int cacheExpirationMinutes, int cacheExpirationMinutes,
int cacheManagementCheckIntervalMinutes, int cacheManagementCheckIntervalMinutes,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Records progress update for a cache update operation using cacheFolderPath.
/// This is useful when locationKey is not directly available (e.g., in ScrapingService).
/// </summary>
/// <param name="cacheFolderPath">The cache folder path being updated</param>
/// <param name="phase">The current phase of the update</param>
/// <param name="currentFrame">The current frame being captured (if in CapturingFrames phase)</param>
/// <param name="totalFrames">The total number of frames to capture</param>
void RecordUpdateProgressByFolder(string cacheFolderPath, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null);
/// <summary>
/// Gets the estimated remaining seconds for an in-progress cache update.
/// Returns 0 if not updating or no metrics available.
/// </summary>
/// <param name="locationKey">The location key (suburb_state)</param>
/// <returns>Estimated remaining seconds, or 0 if not updating or no metrics</returns>
int GetEstimatedRemainingSeconds(string locationKey);
/// <summary>
/// Records step completion timing for metrics tracking.
/// </summary>
/// <param name="stepName">The name of the step</param>
/// <param name="durationSeconds">The duration of the step in seconds</param>
void RecordStepCompletion(string stepName, double durationSeconds);
/// <summary>
/// Gets the average duration for a specific step from historical data.
/// </summary>
/// <param name="stepName">The name of the step</param>
/// <returns>Average duration in seconds, or 0 if no data available</returns>
double GetAverageStepDuration(string stepName);
/// <summary>
/// Gets step performance metrics for debugging/logging.
/// </summary>
/// <returns>Dictionary mapping step names to their average durations</returns>
Dictionary<string, double> GetStepMetrics();
/// <summary>
/// Gets the average total duration of cache updates from recent metrics.
/// Uses median for robustness against outliers.
/// </summary>
/// <returns>Average total duration in seconds, or 0 if no data available</returns>
double GetAverageTotalDuration();
} }
+2 -1
View File
@@ -1,3 +1,4 @@
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright; using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces; namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for debug file generation during web scraping operations. /// Service interface for debug file generation during web scraping operations.
/// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting. /// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting.
/// </summary> /// </summary>
public interface IDebugService public interface IDebugService : ISingletonService
{ {
/// <summary> /// <summary>
/// Indicates whether debug mode is enabled. /// Indicates whether debug mode is enabled.
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models; using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright; using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces; namespace BomLocalService.Services.Interfaces;
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for scraping radar screenshots from the BOM website. /// Service interface for scraping radar screenshots from the BOM website.
/// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots. /// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots.
/// </summary> /// </summary>
public interface IScrapingService public interface IScrapingService : ISingletonService
{ {
/// <summary> /// <summary>
/// Scrapes a radar screenshot from the BOM website for a given location. /// Scrapes a radar screenshot from the BOM website for a given location.
@@ -0,0 +1,27 @@
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
/// <summary>
/// Parses the BOM scrubber time display label to UTC; shared by capture and reset steps.
/// </summary>
public interface IScrubDisplayTimestampParser : ISingletonService
{
Task<DateTime?> ReadUtcAsync(
IPage page,
ISelectorService selectorService,
ScrapingContext? context = null,
CancellationToken cancellationToken = default);
bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc);
Task WaitForDisplayLabelChangeAsync(
IPage page,
ISelectorService selectorService,
DateTime currentTimestamp,
ScrapingContext? context = null,
int maxWaitMs = 5000,
CancellationToken cancellationToken = default);
}
+22
View File
@@ -0,0 +1,22 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
/// <summary>
/// Service for finding page elements using configured selectors
/// </summary>
public interface ISelectorService : ISingletonService
{
/// <summary>
/// Finds an element using the configured selectors, trying each in order until one is found
/// </summary>
Task<ILocator?> FindElementAsync(IPage page, SelectorConfig config, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a locator for the first selector (assumes element exists)
/// </summary>
ILocator GetLocator(IPage page, SelectorConfig config);
}
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models; using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright; using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces; namespace BomLocalService.Services.Interfaces;
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for parsing time and metadata information from BOM website content. /// Service interface for parsing time and metadata information from BOM website content.
/// Extracts observation times, forecast times, weather station names, and distances from BOM weather map pages. /// Extracts observation times, forecast times, weather station names, and distances from BOM weather map pages.
/// </summary> /// </summary>
public interface ITimeParsingService public interface ITimeParsingService : ISingletonService
{ {
/// <summary> /// <summary>
/// Extracts last updated information from a BOM weather map page. /// Extracts last updated information from a BOM weather map page.
@@ -0,0 +1,30 @@
namespace BomLocalService.Services.Interfaces.Registration;
/// <summary>
/// Marker interface for services that should be registered as Singleton
/// </summary>
public interface ISingletonService
{
}
/// <summary>
/// Marker interface for services that should be registered as Scoped
/// </summary>
public interface IScopedService
{
}
/// <summary>
/// Marker interface for services that should be registered as Transient
/// </summary>
public interface ITransientService
{
}
/// <summary>
/// Marker interface for hosted services (BackgroundService implementations)
/// </summary>
public interface IHostedServiceRegistration
{
}
+31
View File
@@ -0,0 +1,31 @@
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Interface for a single scraping step
/// Steps are stateless (all request data is in ScrapingContext), so singleton lifetime is appropriate
/// </summary>
public interface IScrapingStep : ISingletonService
{
/// <summary>
/// Unique name of the step
/// </summary>
string Name { get; }
/// <summary>
/// Names of steps that must complete before this step can execute
/// </summary>
string[] Prerequisites { get; }
/// <summary>
/// Checks if the step can execute in the current page state
/// </summary>
bool CanExecute(ScrapingContext context);
/// <summary>
/// Executes the step
/// </summary>
Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken);
}
@@ -0,0 +1,14 @@
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Registry for managing scraping steps
/// </summary>
public interface IScrapingStepRegistry : ISingletonService
{
void RegisterStep(IScrapingStep step);
IScrapingStep? GetStep(string name);
IEnumerable<IScrapingStep> GetAllSteps();
}
+14
View File
@@ -0,0 +1,14 @@
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Interface for a scraping workflow
/// </summary>
/// <typeparam name="TResponse">The response type returned by this workflow</typeparam>
public interface IWorkflow<TResponse>
{
string Name { get; }
string Description { get; }
string[] StepNames { get; } // Fixed order - cannot be changed
Task<TResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken);
}
+12
View File
@@ -0,0 +1,12 @@
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Factory for creating workflows
/// </summary>
public interface IWorkflowFactory : ISingletonService
{
IWorkflow<TResponse> GetWorkflow<TResponse>(string name);
}
+18
View File
@@ -0,0 +1,18 @@
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Represents the current state of the page during scraping
/// </summary>
public enum PageState
{
Initial,
HomepageLoaded,
SearchModalOpen,
SearchResultsVisible,
ForecastPageLoaded,
RadarPageLoaded,
MapReady,
SlideshowPaused,
Frame0Selected
}
+42
View File
@@ -0,0 +1,42 @@
using BomLocalService.Models;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Context shared between scraping steps
/// </summary>
public class ScrapingContext
{
public IPage Page { get; set; } = null!;
public string Suburb { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string CacheFolderPath { get; set; } = string.Empty;
public string DebugFolder { get; set; } = string.Empty;
public List<(string type, string text, DateTime timestamp)> ConsoleMessages { get; set; } = new();
public List<(string method, string url, int? status, string resourceType, DateTime timestamp)> NetworkRequests { get; set; } = new();
// Page state tracking
public PageState CurrentState { get; set; } = PageState.Initial;
public HashSet<string> CompletedSteps { get; set; } = new();
// Shared state between steps
public ILocator? SearchButton { get; set; }
public ILocator? SearchInput { get; set; }
public List<(string name, string desc, string fullText)>? SearchResults { get; set; }
public int? SelectedResultIndex { get; set; }
public ILocator? RadarLink { get; set; }
public ILocator? MapContainer { get; set; }
public Clip? MapBoundingBox { get; set; }
public LastUpdatedInfo? LastUpdatedInfo { get; set; }
public List<(int index, int minutesAgo)>? FrameInfo { get; set; }
public List<RadarFrame> Frames { get; set; } = new();
// State validation helpers
public bool IsHomepageLoaded => CurrentState >= PageState.HomepageLoaded;
public bool IsSearchModalOpen => CurrentState >= PageState.SearchModalOpen;
public bool IsForecastPageLoaded => CurrentState >= PageState.ForecastPageLoaded;
public bool IsRadarPageLoaded => CurrentState >= PageState.RadarPageLoaded;
public bool IsMapReady => CurrentState >= PageState.MapReady;
}
+59
View File
@@ -0,0 +1,59 @@
using System.Reflection;
namespace BomLocalService.Services.Scraping;
public class ScrapingStepRegistry : IScrapingStepRegistry
{
private readonly Dictionary<string, IScrapingStep> _steps = new();
private readonly ILogger<ScrapingStepRegistry> _logger;
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger, IServiceProvider serviceProvider)
{
_logger = logger;
AutoRegisterSteps(serviceProvider);
}
private void AutoRegisterSteps(IServiceProvider serviceProvider)
{
var stepTypes = typeof(IScrapingStep).Assembly.GetTypes()
.Where(t => typeof(IScrapingStep).IsAssignableFrom(t)
&& !t.IsInterface
&& !t.IsAbstract
&& !t.IsGenericType);
foreach (var stepType in stepTypes)
{
try
{
// Use DI to resolve the step (ensures proper dependency injection)
var step = (IScrapingStep)serviceProvider.GetRequiredService(stepType);
RegisterStep(step);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to auto-register step {StepType}", stepType.Name);
}
}
}
public void RegisterStep(IScrapingStep step)
{
if (_steps.ContainsKey(step.Name))
{
_logger.LogWarning("Step {Name} is already registered, overwriting", step.Name);
}
_steps[step.Name] = step;
_logger.LogDebug("Registered step: {Name}", step.Name);
}
public IScrapingStep? GetStep(string name)
{
return _steps.TryGetValue(name, out var step) ? step : null;
}
public IEnumerable<IScrapingStep> GetAllSteps()
{
return _steps.Values;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Result of executing a scraping step
/// </summary>
public class ScrapingStepResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public Dictionary<string, object>? Data { get; set; }
public static ScrapingStepResult Successful() => new() { Success = true };
public static ScrapingStepResult Failed(string errorMessage) => new() { Success = false, ErrorMessage = errorMessage };
}
@@ -0,0 +1,215 @@
using System.Text.RegularExpressions;
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Parses the BOM scrubber time label (.bom-scrub-display-label) to UTC.
/// Shared by capture and reset steps so behaviour stays in sync.
/// </summary>
public class ScrubDisplayTimestampParser : IScrubDisplayTimestampParser
{
private readonly IConfiguration _configuration;
private readonly ScrapingSelectorsConfig _selectors;
private readonly TextPatternsConfig _textPatterns;
private readonly JavaScriptTemplatesConfig _javaScriptTemplates;
private readonly ILogger<ScrubDisplayTimestampParser> _logger;
public ScrubDisplayTimestampParser(
IConfiguration configuration,
ILogger<ScrubDisplayTimestampParser> logger)
{
_configuration = configuration;
_selectors = configuration.GetSection("Scraping:Selectors").Get<ScrapingSelectorsConfig>() ?? new();
_textPatterns = configuration.GetSection("Scraping:TextPatterns").Get<TextPatternsConfig>() ?? new();
_javaScriptTemplates = configuration.GetSection("Scraping:JavaScriptTemplates").Get<JavaScriptTemplatesConfig>() ?? new();
_logger = logger;
}
public async Task<DateTime?> ReadUtcAsync(
IPage page,
ISelectorService selectorService,
ScrapingContext? context = null,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
try
{
var timeLabelLocator = selectorService.GetLocator(page, _selectors.TimeDisplayLabel);
var timeLabel = await timeLabelLocator.TextContentAsync();
if (string.IsNullOrEmpty(timeLabel))
{
_logger.LogDebug("Time display label is empty");
return null;
}
var trimmedLabel = timeLabel.Trim();
_logger.LogDebug("Scrub display label: '{Label}'", trimmedLabel);
var timestampMatch = Regex.Match(trimmedLabel, _textPatterns.TimestampPattern, RegexOptions.IgnoreCase);
if (!timestampMatch.Success)
{
_logger.LogWarning(
"Display label did not match timestamp pattern. Label: '{Label}', Pattern: '{Pattern}'",
trimmedLabel,
_textPatterns.TimestampPattern);
return null;
}
var timestampStr = timestampMatch.Groups[0].Value;
string? detectedTimezone = null;
if (trimmedLabel.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEDT";
else if (trimmedLabel.Contains("AEST", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEST";
if (detectedTimezone == null && context != null)
{
try
{
var metadataText = await page.EvaluateAsync<string>(_javaScriptTemplates.ExtractWeatherMetadata);
if (!string.IsNullOrEmpty(metadataText))
{
if (metadataText.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEDT";
else if (metadataText.Contains("AEST", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEST";
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract timezone from metadata, using configured timezone");
}
}
if (TryParseTimestamp(timestampStr, detectedTimezone, out var frameTimestampUtc))
return frameTimestampUtc;
_logger.LogWarning("Failed to parse timestamp string: '{Timestamp}'", timestampStr);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract timestamp from display label");
return null;
}
}
public bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc)
{
timestampUtc = DateTime.MinValue;
try
{
TimeZoneInfo timeZoneInfo;
if (!string.IsNullOrEmpty(timezoneAbbreviation))
{
if (timezoneAbbreviation.Contains("AEST", StringComparison.OrdinalIgnoreCase))
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
else if (timezoneAbbreviation.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
else
{
var timezone = _configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
return false;
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
}
}
else
{
var timezone = _configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
return false;
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
}
timestampStr = Regex.Replace(timestampStr.Trim(), @"\s+", " ");
var formats = new[]
{
"dddd d MMM, h:mm tt",
"d MMM, h:mm tt",
"dddd d MMM, hh:mm tt",
"d MMM, hh:mm tt",
"dddd dd MMM, h:mm tt",
"dd MMM, h:mm tt",
"dddd d MMM, h:mmtt",
"d MMM, h:mmtt",
"dddd d MMM, hh:mmtt",
"d MMM, hh:mmtt",
"dddd dd MMM, h:mmtt",
"dd MMM, h:mmtt",
"dddd dd MMM, hh:mmtt",
"dd MMM, hh:mmtt"
};
var culture = new System.Globalization.CultureInfo("en-AU");
DateTime localTime = default;
var parsed = false;
foreach (var format in formats)
{
if (DateTime.TryParseExact(timestampStr, format, culture,
System.Globalization.DateTimeStyles.None, out localTime))
{
parsed = true;
break;
}
}
if (!parsed)
return false;
if (localTime.Year == 1)
{
localTime = new DateTime(DateTime.UtcNow.Year, localTime.Month, localTime.Day,
localTime.Hour, localTime.Minute, localTime.Second);
}
timestampUtc = TimeZoneInfo.ConvertTimeToUtc(localTime, timeZoneInfo);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse timestamp: {Timestamp}", timestampStr);
return false;
}
}
public async Task WaitForDisplayLabelChangeAsync(
IPage page,
ISelectorService selectorService,
DateTime currentTimestamp,
ScrapingContext? context = null,
int maxWaitMs = 5000,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
try
{
var startTime = DateTime.UtcNow;
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
{
var newTimestamp = await ReadUtcAsync(page, selectorService, context, cancellationToken);
if (newTimestamp.HasValue && newTimestamp.Value != currentTimestamp)
return;
await page.WaitForTimeoutAsync(200);
}
_logger.LogDebug(
"Display label did not change from {CurrentTimestamp} within {MaxWaitMs}ms",
currentTimestamp,
maxWaitMs);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error waiting for display label to change");
}
}
}
@@ -0,0 +1,64 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps;
/// <summary>
/// Base class for scraping steps with common functionality
/// </summary>
public abstract class BaseScrapingStep : IScrapingStep
{
protected readonly ILogger Logger;
protected readonly ISelectorService SelectorService;
protected readonly IDebugService DebugService;
protected readonly IConfiguration Configuration;
protected readonly ScrapingSelectorsConfig Selectors;
protected readonly JavaScriptTemplatesConfig JavaScriptTemplates;
protected readonly TextPatternsConfig TextPatterns;
protected BaseScrapingStep(
ILogger logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
{
Logger = logger;
SelectorService = selectorService;
DebugService = debugService;
Configuration = configuration;
Selectors = configuration.GetSection("Scraping:Selectors").Get<ScrapingSelectorsConfig>() ?? new();
JavaScriptTemplates = configuration.GetSection("Scraping:JavaScriptTemplates").Get<JavaScriptTemplatesConfig>() ?? new();
TextPatterns = configuration.GetSection("Scraping:TextPatterns").Get<TextPatternsConfig>() ?? new();
}
public abstract string Name { get; }
public abstract string[] Prerequisites { get; }
public abstract bool CanExecute(ScrapingContext context);
public abstract Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken);
protected async Task SaveDebugAsync(ScrapingContext context, int stepNumber, string stepName, CancellationToken cancellationToken)
{
await DebugService.SaveStepDebugAsync(
context.DebugFolder,
stepNumber,
stepName,
context.Page,
context.ConsoleMessages,
context.NetworkRequests,
cancellationToken);
}
protected async Task SaveErrorDebugAsync(ScrapingContext context, string errorMessage, CancellationToken cancellationToken)
{
await DebugService.SaveErrorDebugAsync(
context.DebugFolder,
errorMessage,
context.Page,
context.ConsoleMessages,
context.NetworkRequests,
cancellationToken);
}
}
@@ -0,0 +1,395 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Capture;
public class CaptureFramesStep : BaseScrapingStep
{
private readonly ICacheService _cacheService;
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
private readonly int _tileRenderWaitMs;
private readonly ScreenshotCropConfig _cropConfig;
public override string Name => "CaptureFrames";
public override string[] Prerequisites => new[] { "ExtractMetadata", "CalculateMapBounds" };
public CaptureFramesStep(
ILogger<CaptureFramesStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration,
ICacheService cacheService,
IScrubDisplayTimestampParser scrubDisplayTime)
: base(logger, selectorService, debugService, configuration)
{
_cacheService = cacheService;
_scrubDisplayTime = scrubDisplayTime;
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
var cropSection = configuration.GetSection("Screenshot:Crop");
var cropXConfig = cropSection.GetValue<int?>("X");
if (!cropXConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:X configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__X environment variable.");
}
var cropYConfig = cropSection.GetValue<int?>("Y");
if (!cropYConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:Y configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__Y environment variable.");
}
var cropRightOffsetConfig = cropSection.GetValue<int?>("RightOffset");
if (!cropRightOffsetConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:RightOffset configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__RIGHTOFFSET environment variable.");
}
_cropConfig = new ScreenshotCropConfig
{
X = cropXConfig.Value,
Y = cropYConfig.Value,
RightOffset = cropRightOffsetConfig.Value,
Height = cropSection.GetValue<int?>("Height") // Height is optional (nullable)
};
}
public override bool CanExecute(ScrapingContext context)
{
return context.MapBoundingBox != null && context.MapContainer != null;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Directory.CreateDirectory(context.CacheFolderPath);
Logger.LogInformation("Step {Step}: Using cache folder: {Path}", Name, context.CacheFolderPath);
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.CapturingFrames, 0, frameCount);
var frames = new List<RadarFrame>();
var stepForwardButton = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
DateTime? previousTimestamp = null;
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
Logger.LogInformation("Step {Step}: Capturing frame {FrameIndex} of {FrameCount}", Name, frameIndex, frameCount);
await context.Page.WaitForTimeoutAsync(_tileRenderWaitMs);
// Small wait to ensure display label is stable after frame change/selection
// This is especially important for frame 0 which was just selected in ResetToFirstFrame
await context.Page.WaitForTimeoutAsync(300);
// Try extracting timestamp with a retry in case the label is still updating
var frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (frameTimestamp == null)
{
// Retry once after a short wait in case label was updating
await context.Page.WaitForTimeoutAsync(200);
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
}
// Fallback: calculate expected timestamp from observation time and frame index if we can't parse it
if (frameTimestamp == null && context.LastUpdatedInfo?.ObservationTime != null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
{
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
Logger.LogWarning("Step {Step}: Failed to extract timestamp from display label for frame {FrameIndex}, calculated from observation time: {Timestamp}",
Name, frameIndex, frameTimestamp);
}
if (frameIndex > 0 && previousTimestamp.HasValue && frameTimestamp == previousTimestamp.Value)
{
Logger.LogWarning("Step {Step}: Frame {FrameIndex} has same timestamp ({Timestamp}) as previous frame. Waiting for display to update...",
Name, frameIndex, frameTimestamp);
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, previousTimestamp.Value, context, cancellationToken: cancellationToken);
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (frameTimestamp == null || frameTimestamp == previousTimestamp.Value)
{
if (context.LastUpdatedInfo?.ObservationTime != null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
{
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, calculated from observation time: {Timestamp}",
Name, frameIndex, frameTimestamp);
}
}
}
if (frameTimestamp == null)
{
Logger.LogError("Step {Step}: Could not determine timestamp for frame {FrameIndex}, skipping", Name, frameIndex);
continue;
}
var radarFolder = FilePathHelper.GetDataTypeFolderPath(context.CacheFolderPath, CachedDataType.Radar);
if (!Directory.Exists(radarFolder))
{
Directory.CreateDirectory(radarFolder);
}
var framePath = FilePathHelper.GetFrameFilePath(context.CacheFolderPath, CachedDataType.Radar, frameIndex);
await CaptureMapScreenshotAsync(context.Page, context.MapContainer!, framePath, context.MapBoundingBox!);
frames.Add(new RadarFrame
{
FrameIndex = frameIndex,
ImagePath = framePath,
AbsoluteObservationTime = frameTimestamp.Value
});
previousTimestamp = frameTimestamp;
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} (timestamp: {Timestamp} UTC)",
Name, frameIndex, framePath, frameTimestamp.Value);
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.CapturingFrames, frameIndex + 1, frameCount);
await SaveDebugAsync(context, 15 + frameIndex, $"frame_{frameIndex}_captured", cancellationToken);
if (frameIndex < frameCount - 1)
{
await DismissModalOverlaysAsync(context.Page);
var currentTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
if (currentTimestamp.HasValue)
{
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, currentTimestamp.Value, context, cancellationToken: cancellationToken);
}
else
{
await context.Page.WaitForTimeoutAsync(500);
}
}
}
Logger.LogInformation("Step {Step}: All {FrameCount} frames captured successfully", Name, frameCount);
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.Saving);
if (context.LastUpdatedInfo != null)
{
await _cacheService.SaveMetadataAsync(context.CacheFolderPath, context.LastUpdatedInfo, cancellationToken);
}
await _cacheService.SaveFramesMetadataAsync(context.CacheFolderPath, CachedDataType.Radar, frames, cancellationToken);
context.Frames = frames;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to capture frames: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to capture frames: {ex.Message}");
}
}
private async Task DismissModalOverlaysAsync(IPage page)
{
try
{
var hasModal = await page.EvaluateAsync<bool>(JavaScriptTemplates.CheckModalOverlay);
if (!hasModal)
{
return;
}
Logger.LogDebug("Modal overlay detected, dismissing");
await page.Keyboard.PressAsync("Escape");
await page.WaitForTimeoutAsync(200);
var stillVisible = await page.EvaluateAsync<bool>(JavaScriptTemplates.CheckModalStillVisible);
if (stillVisible)
{
try
{
var mapContainer = SelectorService.GetLocator(page, Selectors.MapContainer);
await mapContainer.ClickAsync(new LocatorClickOptions { Force = true });
await page.WaitForTimeoutAsync(100);
}
catch
{
// Ignore if click fails
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Error dismissing modal overlay, continuing");
}
}
private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip)
{
if (containerClip == null || containerClip.Width <= 0 || containerClip.Height <= 0)
{
Logger.LogError("Invalid container bounds: X={X}, Y={Y}, Width={Width}, Height={Height}",
containerClip?.X ?? 0, containerClip?.Y ?? 0, containerClip?.Width ?? 0, containerClip?.Height ?? 0);
throw new Exception($"Invalid container bounds: {containerClip?.Width ?? 0}x{containerClip?.Height ?? 0}");
}
Clip cropArea;
try
{
cropArea = CalculateCropArea(containerClip);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to calculate crop area, using full container bounds");
cropArea = containerClip;
}
var viewportSize = page.ViewportSize;
int? viewportWidth = viewportSize?.Width;
int? viewportHeight = viewportSize?.Height;
if (viewportWidth == null || viewportHeight == null)
{
try
{
var viewportJson = await page.EvaluateAsync<string>(JavaScriptTemplates.GetViewportSize);
if (!string.IsNullOrEmpty(viewportJson))
{
using var doc = System.Text.Json.JsonDocument.Parse(viewportJson);
var root = doc.RootElement;
if (root.TryGetProperty("width", out var widthProp) && root.TryGetProperty("height", out var heightProp))
{
if (widthProp.TryGetInt32(out var width) && heightProp.TryGetInt32(out var height))
{
viewportWidth = width;
viewportHeight = height;
}
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to get viewport size from page evaluation");
}
if (viewportWidth == null || viewportHeight == null)
{
viewportWidth = (int)containerClip.Width;
viewportHeight = (int)containerClip.Height;
}
}
if (viewportWidth.HasValue && viewportHeight.HasValue)
{
if (cropArea.X < 0)
{
cropArea = new Clip { X = 0, Y = cropArea.Y, Width = cropArea.Width + cropArea.X, Height = cropArea.Height };
}
if (cropArea.Y < 0)
{
cropArea = new Clip { X = cropArea.X, Y = 0, Width = cropArea.Width, Height = cropArea.Height + cropArea.Y };
}
if (cropArea.X + cropArea.Width > viewportWidth.Value)
{
var newWidth = viewportWidth.Value - cropArea.X;
cropArea = new Clip { X = cropArea.X, Y = cropArea.Y, Width = newWidth, Height = cropArea.Height };
}
if (cropArea.Y + cropArea.Height > viewportHeight.Value)
{
var newHeight = viewportHeight.Value - cropArea.Y;
cropArea = new Clip { X = cropArea.X, Y = cropArea.Y, Width = cropArea.Width, Height = newHeight };
}
}
if (cropArea.Width <= 0 || cropArea.Height <= 0)
{
Logger.LogError("Invalid crop dimensions after validation: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height);
cropArea = containerClip;
}
if (cropArea.Width <= 0 || cropArea.Height <= 0)
{
throw new Exception($"Cannot create valid crop area. Container: {containerClip.Width}x{containerClip.Height}, Viewport: {viewportWidth ?? 0}x{viewportHeight ?? 0}");
}
try
{
await page.WaitForLoadStateAsync(LoadState.NetworkIdle, new PageWaitForLoadStateOptions { Timeout = 5000 });
}
catch
{
// Continue if network idle timeout
}
await DismissModalOverlaysAsync(page);
await page.ScreenshotAsync(new PageScreenshotOptions
{
Path = outputPath,
Clip = cropArea,
Type = ScreenshotType.Png,
Animations = ScreenshotAnimations.Disabled
});
Logger.LogDebug("Screenshot saved: {Path} (crop: {X},{Y} {Width}x{Height})",
outputPath, cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height);
}
private Clip CalculateCropArea(Clip containerClip)
{
var x = containerClip.X + _cropConfig.X;
var y = containerClip.Y + _cropConfig.Y;
var width = Math.Max(0, containerClip.Width - _cropConfig.X - _cropConfig.RightOffset);
var height = _cropConfig.Height ?? Math.Max(0, containerClip.Height - _cropConfig.Y);
if (x < containerClip.X || y < containerClip.Y)
{
x = containerClip.X;
y = containerClip.Y;
}
var maxWidth = containerClip.Width - (x - containerClip.X);
var maxHeight = containerClip.Height - (y - containerClip.Y);
if (width > maxWidth)
{
width = maxWidth;
}
if (height > maxHeight)
{
height = maxHeight;
}
if (width <= 0 || height <= 0)
{
throw new Exception($"Invalid crop dimensions: {width}x{height}");
}
return new Clip
{
X = x,
Y = y,
Width = width,
Height = height
};
}
}
@@ -0,0 +1,69 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Map;
public class CalculateMapBoundsStep : BaseScrapingStep
{
public override string Name => "CalculateMapBounds";
public override string[] Prerequisites => new[] { "ResetToFirstFrame" };
public CalculateMapBoundsStep(
ILogger<CalculateMapBoundsStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsMapReady;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Preparing map container for screenshot", Name);
var mapContainer = SelectorService.GetLocator(context.Page, Selectors.MapContainer);
await mapContainer.WaitForAsync(new LocatorWaitForOptions { Timeout = 10000 });
await context.Page.WaitForFunctionAsync(
JavaScriptTemplates.WaitForMapContainer,
new PageWaitForFunctionOptions { Timeout = 10000 });
var boundingBox = await mapContainer.BoundingBoxAsync();
if (boundingBox == null || boundingBox.Width <= 0 || boundingBox.Height <= 0)
{
var errorMsg = $"Map container has invalid bounds: {boundingBox?.Width ?? 0}x{boundingBox?.Height ?? 0}";
Logger.LogError("Step {Step}: {Error}", Name, errorMsg);
await SaveErrorDebugAsync(context, errorMsg, cancellationToken);
return ScrapingStepResult.Failed(errorMsg);
}
var containerClip = new Clip
{
X = boundingBox.X,
Y = boundingBox.Y,
Width = boundingBox.Width,
Height = boundingBox.Height
};
context.MapContainer = mapContainer;
context.MapBoundingBox = containerClip;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to calculate map bounds: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to calculate map bounds: {ex.Message}");
}
}
}
@@ -0,0 +1,93 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Map;
public class PauseRadarStep : BaseScrapingStep
{
public override string Name => "PauseRadar";
public override string[] Prerequisites => new[] { "WaitForMapReady" };
public PauseRadarStep(
ILogger<PauseRadarStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsMapReady;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Checking if radar loop is paused", Name);
var playPauseButton = SelectorService.GetLocator(context.Page, Selectors.PlayPauseButton);
await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
// New spatial map often uses one step-through control; label may be on the button or in .bom-scrub-action__label
string? buttonLabel = null;
try
{
var labelLoc = playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).First;
if (await labelLoc.CountAsync() > 0)
buttonLabel = (await labelLoc.TextContentAsync())?.Trim();
}
catch
{
/* use inner text */
}
buttonLabel ??= (await playPauseButton.InnerTextAsync())?.Trim();
if (buttonLabel?.Equals(TextPatterns.ExpectedTexts["PauseButtonLabel"], StringComparison.OrdinalIgnoreCase) == true)
{
Logger.LogInformation("Step {Step}: Radar is playing, pausing it", Name);
await playPauseButton.ClickAsync();
await context.Page.WaitForTimeoutAsync(300);
// Clear so post-click read always runs; otherwise stale "Pause" skips the ??= InnerText fallback when the label child is missing
buttonLabel = null;
try
{
var labelLoc = playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).First;
if (await labelLoc.CountAsync() > 0)
buttonLabel = (await labelLoc.TextContentAsync())?.Trim();
}
catch
{
buttonLabel = null;
}
buttonLabel ??= (await playPauseButton.InnerTextAsync())?.Trim();
if (buttonLabel?.Equals(TextPatterns.ExpectedTexts["PlayButtonLabel"], StringComparison.OrdinalIgnoreCase) != true)
{
Logger.LogWarning("Step {Step}: Radar may not be paused after click, continuing anyway", Name);
}
}
else
{
Logger.LogInformation("Step {Step}: Radar is already paused", Name);
}
await SaveDebugAsync(context, 8, "radar_paused", cancellationToken);
context.CurrentState = PageState.SlideshowPaused;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to pause radar: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to pause radar: {ex.Message}");
}
}
}
@@ -0,0 +1,142 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Map;
/// <summary>
/// Aligns the radar scrubber to the oldest frame (historical frame 0) without clicking timeline segments.
/// The BOM UI now often exposes a single step-through control; segment pills and <c>bom-scrub-tl</c> are unreliable.
/// Strategy: while paused, read <see cref="IScrubDisplayTimestampParser"/> from the display label, step forward
/// through one full loop to find the minimum time, then step until that time shows again.
/// </summary>
public class ResetToFirstFrameStep : BaseScrapingStep
{
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
public override string Name => "ResetToFirstFrame";
public override string[] Prerequisites => new[] { "ExtractMetadata" };
public ResetToFirstFrameStep(
ILogger<ResetToFirstFrameStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration,
IScrubDisplayTimestampParser scrubDisplayTime)
: base(logger, selectorService, debugService, configuration)
{
_scrubDisplayTime = scrubDisplayTime;
}
public override bool CanExecute(ScrapingContext context)
{
return context.CurrentState >= PageState.SlideshowPaused
&& context.LastUpdatedInfo != null;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Aligning to oldest frame via step-through + display label (no segment clicks)", Name);
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
var elementWaitMs = Configuration.GetValue<int?>("Scraping:Timeouts:ElementWait") ?? 10000;
var stepBtn = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
await stepBtn.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = elementWaitMs });
// Phase 1 — sample one full loop of display times while paused; oldest = frame 0 target
var samples = new List<DateTime>();
for (var i = 0; i < frameCount; i++)
{
var t = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (t.HasValue)
samples.Add(t.Value);
if (i == frameCount - 1)
break;
var beforeStep = t;
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
if (beforeStep.HasValue)
{
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, beforeStep.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
}
else
await context.Page.WaitForTimeoutAsync(800);
}
DateTime? frame0Utc = null;
if (samples.Count > 0)
{
frame0Utc = samples.Min();
Logger.LogInformation("Step {Step}: Oldest time in sampled loop (frame 0 target): {Utc:o} UTC", Name, frame0Utc);
}
else
{
Logger.LogWarning("Step {Step}: Could not read any display timestamps; tile timing may be wrong", Name);
}
// Phase 2 — step until the display matches the oldest sample
if (frame0Utc.HasValue)
{
const double matchMinutes = 2.0;
for (var attempt = 0; attempt < frameCount + 4; attempt++)
{
var cur = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (cur.HasValue && Math.Abs((cur.Value - frame0Utc.Value).TotalMinutes) <= matchMinutes)
{
Logger.LogInformation("Step {Step}: Display matches frame 0 target (≤{MatchMinutes:F0} min)", Name, matchMinutes);
break;
}
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
if (cur.HasValue)
{
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, cur.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
}
else
await context.Page.WaitForTimeoutAsync(800);
}
}
await SaveDebugAsync(context, 9, "frame_0_aligned", cancellationToken);
// Best-effort only: segment-based checks are unreliable on the new spatial map layout
try
{
var onSegmentZero = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
if (onSegmentZero)
Logger.LogDebug("Step {Step}: Segment check reports frame 0 (informational)", Name);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Step {Step}: Segment position check skipped", Name);
}
await SaveDebugAsync(context, 10, "scrubber_frame0", cancellationToken);
var tileRenderWaitMsConfig = Configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
await context.Page.WaitForTimeoutAsync(tileRenderWaitMsConfig.Value);
context.CurrentState = PageState.Frame0Selected;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to reset to first frame: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to reset to first frame: {ex.Message}");
}
}
}
@@ -0,0 +1,78 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Map;
public class WaitForMapReadyStep : BaseScrapingStep
{
private readonly int _tileRenderWaitMs;
public override string Name => "WaitForMapReady";
public override string[] Prerequisites => new[] { "ClickRadarLink" };
public WaitForMapReadyStep(
ILogger<WaitForMapReadyStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsRadarPageLoaded;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Waiting for weather map page to load", Name);
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
Logger.LogInformation("Step {Step}: Waiting for map canvas element to render", Name);
var mapCanvas = SelectorService.GetLocator(context.Page, Selectors.MapCanvas);
await mapCanvas.WaitForAsync(new LocatorWaitForOptions { Timeout = 15000 });
await context.Page.WaitForFunctionAsync(
JavaScriptTemplates.WaitForMapCanvas,
new PageWaitForFunctionOptions { Timeout = 15000 });
Logger.LogInformation("Step {Step}: Map canvas is ready - waiting for map to render", Name);
try
{
await context.Page.WaitForFunctionAsync(
JavaScriptTemplates.WaitForEsriView,
new PageWaitForFunctionOptions { Timeout = 30000 });
Logger.LogInformation("Step {Step}: Esri map view is ready", Name);
}
catch
{
Logger.LogInformation("Step {Step}: Esri view ready check timed out, continuing with fixed wait", Name);
}
await context.Page.WaitForTimeoutAsync(_tileRenderWaitMs);
await SaveDebugAsync(context, 7, "weather_map_ready", cancellationToken);
context.CurrentState = PageState.MapReady;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to wait for map ready: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to wait for map ready: {ex.Message}");
}
}
}
@@ -0,0 +1,96 @@
using System.Text.Json;
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
namespace BomLocalService.Services.Scraping.Steps.Metadata;
public class ExtractMetadataStep : BaseScrapingStep
{
private readonly ITimeParsingService _timeParsingService;
public override string Name => "ExtractMetadata";
public override string[] Prerequisites => new[] { "PauseRadar" };
public ExtractMetadataStep(
ILogger<ExtractMetadataStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration,
ITimeParsingService timeParsingService)
: base(logger, selectorService, debugService, configuration)
{
_timeParsingService = timeParsingService;
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsMapReady;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Extracting metadata and frame information", Name);
var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(context.Page);
context.LastUpdatedInfo = lastUpdatedInfo;
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
var defaults = Enumerable.Range(0, frameCount)
.Select(i => (index: i, minutesAgo: 40 - i * 5))
.ToList();
try
{
var element = await context.Page.EvaluateAsync<JsonElement>(JavaScriptTemplates.ExtractFrameInfo);
if (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() > 0)
{
var byIndex = new Dictionary<int, int>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
continue;
var idx = item.GetProperty("index").GetInt32();
if (item.TryGetProperty("minutesAgo", out var m) && m.ValueKind == JsonValueKind.Number)
byIndex[idx] = m.GetInt32();
}
if (byIndex.Count > 0)
{
context.FrameInfo = Enumerable.Range(0, frameCount)
.Select(i => (i, byIndex.TryGetValue(i, out var mm) ? mm : defaults[i].minutesAgo))
.ToList();
Logger.LogInformation("Step {Step}: Parsed frame offsets from segment aria-labels where available", Name);
}
else
{
context.FrameInfo = defaults;
Logger.LogInformation("Step {Step}: Segment times not parsed; using default ladder", Name);
}
}
else
{
context.FrameInfo = defaults;
Logger.LogInformation("Step {Step}: No segment array from page; using default ladder", Name);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Step {Step}: Failed to extract frame info, using defaults", Name);
context.FrameInfo = defaults;
}
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to extract metadata: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to extract metadata: {ex.Message}");
}
}
}
@@ -0,0 +1,56 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Navigation;
public class ClickRadarLinkStep : BaseScrapingStep
{
public override string Name => "ClickRadarLink";
public override string[] Prerequisites => new[] { "SelectSearchResult" };
public ClickRadarLinkStep(
ILogger<ClickRadarLinkStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsForecastPageLoaded;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Looking for 'Rain radar and weather map' link", Name);
var radarLink = await SelectorService.FindElementAsync(context.Page, Selectors.RadarLink, cancellationToken);
if (radarLink == null)
{
var errorMsg = Selectors.RadarLink.ErrorMessage ?? $"Could not find 'Rain radar and weather map' link for {context.Suburb}, {context.State}";
await SaveErrorDebugAsync(context, errorMsg, cancellationToken);
return ScrapingStepResult.Failed(errorMsg);
}
context.RadarLink = radarLink;
await radarLink.ClickAsync();
await SaveDebugAsync(context, 6, "radar_link_clicked", cancellationToken);
context.CurrentState = PageState.RadarPageLoaded;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to click radar link: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to click radar link: {ex.Message}");
}
}
}
@@ -0,0 +1,65 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Navigation;
public class ClickSearchButtonStep : BaseScrapingStep
{
public override string Name => "ClickSearchButton";
public override string[] Prerequisites => new[] { "NavigateHomepage" };
public ClickSearchButtonStep(
ILogger<ClickSearchButtonStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsHomepageLoaded;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Clicking 'Search for a location' button", Name);
var searchButton = await SelectorService.FindElementAsync(context.Page, Selectors.SearchButton, cancellationToken);
if (searchButton == null)
{
var errorMsg = Selectors.SearchButton.ErrorMessage ?? "Could not find 'Search for a location' button on BOM homepage.";
await SaveErrorDebugAsync(context, errorMsg, cancellationToken);
return ScrapingStepResult.Failed(errorMsg);
}
context.SearchButton = searchButton;
await searchButton.ClickAsync();
// Wait for search input to appear
var searchInputReady = SelectorService.GetLocator(context.Page, Selectors.SearchInput);
await searchInputReady.WaitForAsync(new LocatorWaitForOptions
{
Timeout = Selectors.SearchInput.TimeoutMs,
State = WaitForSelectorState.Visible
});
await SaveDebugAsync(context, 2, "search_button_clicked", cancellationToken);
context.CurrentState = PageState.SearchModalOpen;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to click search button: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to click search button: {ex.Message}");
}
}
}
@@ -0,0 +1,63 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Navigation;
public class NavigateHomepageStep : BaseScrapingStep
{
private readonly string _baseUrl;
public override string Name => "NavigateHomepage";
public override string[] Prerequisites => Array.Empty<string>();
public NavigateHomepageStep(
ILogger<NavigateHomepageStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
_baseUrl = configuration.GetValue<string>("Scraping:BaseUrl")
?? throw new InvalidOperationException("Scraping:BaseUrl configuration is required. Set it in appsettings.json or via SCRAPING__BASEURL environment variable.");
}
public override bool CanExecute(ScrapingContext context)
{
return context.CurrentState == PageState.Initial;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Navigating to BOM homepage", Name);
await context.Page.GotoAsync(_baseUrl, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 30000
});
// Wait for search button to be ready
var searchButtonReady = SelectorService.GetLocator(context.Page, Selectors.SearchButton);
await searchButtonReady.WaitForAsync(new LocatorWaitForOptions
{
Timeout = Selectors.SearchButton.TimeoutMs,
State = WaitForSelectorState.Visible
});
await SaveDebugAsync(context, 1, "homepage_loaded", cancellationToken);
context.CurrentState = PageState.HomepageLoaded;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to navigate to homepage: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to navigate to homepage: {ex.Message}");
}
}
}
@@ -0,0 +1,48 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Search;
public class FillSearchInputStep : BaseScrapingStep
{
public override string Name => "FillSearchInput";
public override string[] Prerequisites => new[] { "ClickSearchButton" };
public FillSearchInputStep(
ILogger<FillSearchInputStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsSearchModalOpen;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Searching for suburb: {Suburb}", Name, context.Suburb);
var searchInput = SelectorService.GetLocator(context.Page, Selectors.SearchInput);
await searchInput.FillAsync(context.Suburb);
context.SearchInput = searchInput;
await SaveDebugAsync(context, 3, "search_input_filled", cancellationToken);
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to fill search input: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to fill search input: {ex.Message}");
}
}
}
@@ -0,0 +1,197 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
using Microsoft.Playwright;
using System.Text.RegularExpressions;
namespace BomLocalService.Services.Scraping.Steps.Search;
public class SelectSearchResultStep : BaseScrapingStep
{
public override string Name => "SelectSearchResult";
public override string[] Prerequisites => new[] { "WaitForSearchResults" };
public SelectSearchResultStep(
ILogger<SelectSearchResultStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.CurrentState >= PageState.SearchResultsVisible;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Looking for matching search result for {Suburb}, {State}", Name, context.Suburb, context.State);
var suburbLower = context.Suburb.ToLower().Trim();
var stateLower = context.State.ToLower().Trim();
// Get the actual count from the summary element
var resultsTitle = SelectorService.GetLocator(context.Page, Selectors.ResultsTitle);
var summaryText = await resultsTitle.TextContentAsync();
int? actualCount = null;
if (!string.IsNullOrEmpty(summaryText))
{
var countMatch = Regex.Match(summaryText, TextPatterns.ResultsCountPattern, RegexOptions.IgnoreCase);
if (countMatch.Success && countMatch.Groups.Count >= 3 && countMatch.Groups[2].Success)
{
if (int.TryParse(countMatch.Groups[2].Value, out var total))
{
actualCount = total;
}
}
}
// Fetch all result data
List<(string name, string desc, string fullText)> results = new();
try
{
var resultData = await context.Page.EvaluateAsync<string[][]>(JavaScriptTemplates.ExtractSearchResults);
results = resultData.Select(arr => (
name: arr.Length > 0 ? arr[0] : "",
desc: arr.Length > 1 ? arr[1] : "",
fullText: arr.Length > 2 ? arr[2] : ""
)).ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to extract structured result data, falling back to text content");
var resultTexts = await context.Page.EvaluateAsync<string[]>(JavaScriptTemplates.ExtractSearchResultsFallback);
results = resultTexts.Select(text => (name: "", desc: "", fullText: text)).ToList();
}
if (actualCount.HasValue)
{
Logger.LogInformation("Found {Count} location results (summary: {Summary})", actualCount.Value, summaryText?.Trim());
if (results.Count > actualCount.Value)
{
results = results.Take(actualCount.Value).ToList();
}
}
else
{
Logger.LogInformation("Found {Count} search results", results.Count);
}
int? matchingIndex = null;
int bestMatchScore = -1;
for (int i = 0; i < results.Count; i++)
{
var (name, desc, fullText) = results[i];
var nameLower = name.ToLower().Trim();
var descLower = desc.ToLower().Trim();
var fullTextLower = fullText.ToLower();
Logger.LogInformation("Checking result {Index}: Name='{Name}', Desc='{Desc}', FullText='{FullText}'",
i, name, desc, fullText.Length > 100 ? fullText.Substring(0, 100) + "..." : fullText);
var matchesSuburb = false;
var matchScore = 0;
if (!string.IsNullOrEmpty(name))
{
if (nameLower == suburbLower)
{
matchesSuburb = true;
matchScore = 100;
}
else if (nameLower.StartsWith(suburbLower + " ") || nameLower.StartsWith(suburbLower + "("))
{
matchesSuburb = true;
matchScore = 80;
}
else if (nameLower.Contains("(" + suburbLower + ")") || nameLower.Contains("(" + suburbLower + " "))
{
matchesSuburb = true;
matchScore = 60;
}
else if (nameLower.Contains(suburbLower))
{
matchesSuburb = true;
matchScore = 40;
}
}
else
{
if (fullTextLower.Contains(suburbLower))
{
matchesSuburb = true;
matchScore = 20;
}
}
var matchesState = false;
if (!string.IsNullOrEmpty(desc))
{
matchesState = StateAbbreviationHelper.MatchesState(descLower, stateLower);
}
if (!matchesState)
{
matchesState = StateAbbreviationHelper.MatchesState(fullTextLower, stateLower);
}
Logger.LogInformation("Result {Index}: matchesSuburb={MatchesSuburb} (score={Score}), matchesState={MatchesState}",
i, matchesSuburb, matchScore, matchesState);
if (matchesSuburb && matchesState && matchScore > bestMatchScore)
{
matchingIndex = i;
bestMatchScore = matchScore;
Logger.LogInformation("New best match found: {Name} - {Desc} (score: {Score})", name, desc, matchScore);
}
}
if (matchingIndex.HasValue)
{
Logger.LogInformation("Using best matching result at index {Index} with score {Score}", matchingIndex.Value, bestMatchScore);
}
else
{
Logger.LogInformation("No exact match found, using first result");
}
context.SearchResults = results;
context.SelectedResultIndex = matchingIndex;
// Click the matching result
var resultsList = context.Page.Locator("ul[aria-labelledby='location-results-title']");
var allResults = resultsList.Locator("li.bom-linklist__item[role='listitem']");
var resultToClick = matchingIndex.HasValue ? allResults.Nth(matchingIndex.Value) : allResults.First;
await resultToClick.ClickAsync();
await SaveDebugAsync(context, 4, "search_result_selected", cancellationToken);
// Wait for forecast page to load
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
var dynamicContentWaitMsConfig = Configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
if (!dynamicContentWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
}
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
await context.Page.WaitForTimeoutAsync(dynamicContentWaitMs);
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
context.CurrentState = PageState.ForecastPageLoaded;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to select search result: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to select search result: {ex.Message}");
}
}
}
@@ -0,0 +1,47 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Search;
public class WaitForSearchResultsStep : BaseScrapingStep
{
public override string Name => "WaitForSearchResults";
public override string[] Prerequisites => new[] { "FillSearchInput" };
public WaitForSearchResultsStep(
ILogger<WaitForSearchResultsStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsSearchModalOpen;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Waiting for autocomplete suggestions", Name);
await context.Page.WaitForFunctionAsync(
JavaScriptTemplates.WaitForSearchResults,
new PageWaitForFunctionOptions { Timeout = 10000 });
context.CurrentState = PageState.SearchResultsVisible;
return ScrapingStepResult.Successful();
}
catch (Exception ex)
{
Logger.LogError(ex, "Step {Step} failed", Name);
await SaveErrorDebugAsync(context, $"Failed to wait for search results: {ex.Message}", cancellationToken);
return ScrapingStepResult.Failed($"Failed to wait for search results: {ex.Message}");
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using BomLocalService.Services.Scraping.Workflows;
namespace BomLocalService.Services.Scraping;
public class WorkflowFactory : IWorkflowFactory
{
private readonly IServiceProvider _serviceProvider;
public WorkflowFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IWorkflow<TResponse> GetWorkflow<TResponse>(string name)
{
return name switch
{
"RadarScraping" => _serviceProvider.GetRequiredService<IRadarScrapingWorkflow>() as IWorkflow<TResponse>
?? throw new InvalidOperationException($"IRadarScrapingWorkflow is not IWorkflow<{typeof(TResponse).Name}>"),
"TemperatureMap" => _serviceProvider.GetRequiredService<ITemperatureMapWorkflow>() as IWorkflow<TResponse>
?? throw new InvalidOperationException($"ITemperatureMapWorkflow is not IWorkflow<{typeof(TResponse).Name}>"),
_ => throw new ArgumentException($"Unknown workflow: {name}")
};
}
}
@@ -0,0 +1,12 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping.Workflows;
/// <summary>
/// Concrete interface for radar scraping workflow (hides generic IWorkflow&lt;RadarResponse&gt;)
/// </summary>
public interface IRadarScrapingWorkflow : IWorkflow<RadarResponse>, IScopedService
{
}
@@ -0,0 +1,12 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping.Workflows;
/// <summary>
/// Concrete interface for temperature map workflow (hides generic IWorkflow&lt;RadarResponse&gt;)
/// </summary>
public interface ITemperatureMapWorkflow : IWorkflow<RadarResponse>, IScopedService
{
}
@@ -0,0 +1,222 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
namespace BomLocalService.Services.Scraping.Workflows;
public class RadarScrapingWorkflow : IRadarScrapingWorkflow
{
private readonly ILogger<RadarScrapingWorkflow> _logger;
private readonly IScrapingStepRegistry _stepRegistry;
private readonly IConfiguration _configuration;
private readonly ICacheService _cacheService;
private readonly double _cacheExpirationMinutes;
private readonly int _cacheManagementCheckIntervalMinutes;
public string Name => "RadarScraping";
public string Description => "Scrapes radar images for a location";
// Fixed step sequence - order matters due to dependencies
public string[] StepNames => new[]
{
"NavigateHomepage",
"ClickSearchButton",
"FillSearchInput",
"WaitForSearchResults",
"SelectSearchResult",
"ClickRadarLink",
"WaitForMapReady",
"PauseRadar",
"ExtractMetadata",
"ResetToFirstFrame",
"CalculateMapBounds",
"CaptureFrames"
};
public RadarScrapingWorkflow(
ILogger<RadarScrapingWorkflow> logger,
IScrapingStepRegistry stepRegistry,
IConfiguration configuration,
ICacheService cacheService)
{
_logger = logger;
_stepRegistry = stepRegistry;
_configuration = configuration;
_cacheService = cacheService;
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
}
public async Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
var workflowConfig = _configuration.GetSection($"Scraping:Workflows:{Name}").Get<ScrapingWorkflowConfig>();
var workflowStartTime = DateTime.UtcNow;
var stepTimings = new List<(string stepName, double durationSeconds)>();
// Record Initializing phase start for metrics tracking
if (!string.IsNullOrEmpty(context.CacheFolderPath))
{
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.Initializing);
}
foreach (var stepName in StepNames)
{
var stepConfig = workflowConfig?.Steps?.GetValueOrDefault(stepName) ?? new ScrapingStepConfig { Enabled = true };
if (!stepConfig.Enabled)
{
_logger.LogInformation("Step {Step} is disabled, skipping", stepName);
continue;
}
var step = _stepRegistry.GetStep(stepName);
if (step == null)
{
throw new InvalidOperationException($"Step {stepName} not found in registry");
}
if (!ValidatePrerequisites(step, context))
{
throw new InvalidOperationException(
$"Step {stepName} prerequisites not met. Required: {string.Join(", ", step.Prerequisites)}");
}
if (!step.CanExecute(context))
{
throw new InvalidOperationException(
$"Step {stepName} cannot execute in current page state: {context.CurrentState}");
}
var stepStartTime = DateTime.UtcNow;
_logger.LogInformation("Executing step {Step}", stepName);
var result = await step.ExecuteAsync(context, cancellationToken);
var stepDuration = (DateTime.UtcNow - stepStartTime).TotalSeconds;
stepTimings.Add((stepName, stepDuration));
// Record step timing in metrics
_cacheService.RecordStepCompletion(stepName, stepDuration);
// Get historical average for comparison
var avgDuration = _cacheService.GetAverageStepDuration(stepName);
if (avgDuration > 0)
{
var diff = stepDuration - avgDuration;
var diffPercent = (diff / avgDuration) * 100;
var isSlow = stepDuration > avgDuration * 1.5; // 50% slower than average
if (isSlow)
{
_logger.LogWarning("Step {Step} took significantly longer than average: {Duration:F2}s (avg: {Avg:F2}s, {Diff:+#.##}s, {DiffPercent:+#0.#}% slower)",
stepName, stepDuration, avgDuration, diff, diffPercent);
}
else if (Math.Abs(diffPercent) > 20) // More than 20% difference (faster or slower)
{
_logger.LogInformation("Step {Step} completed in {Duration:F2}s (avg: {Avg:F2}s, {Diff:+#.##;-#.##}s, {DiffPercent:+#0.#;-#0.#}%)",
stepName, stepDuration, avgDuration, diff, diffPercent);
}
else
{
_logger.LogInformation("Step {Step} completed in {Duration:F2}s (avg: {Avg:F2}s)", stepName, stepDuration, avgDuration);
}
}
else
{
_logger.LogInformation("Step {Step} completed in {Duration:F2}s", stepName, stepDuration);
}
if (!result.Success)
{
throw new Exception($"Step {stepName} failed: {result.ErrorMessage}");
}
context.CompletedSteps.Add(stepName);
}
var totalDuration = (DateTime.UtcNow - workflowStartTime).TotalSeconds;
// Check if workflow duration is significantly longer than average
var avgTotalDuration = _cacheService.GetAverageTotalDuration();
var isWorkflowSlow = avgTotalDuration > 0 && totalDuration > avgTotalDuration * 1.3; // 30% slower than average
// Log step breakdown
var stepBreakdown = string.Join(", ", stepTimings.Select(t => $"{t.stepName}={t.durationSeconds:F2}s"));
if (isWorkflowSlow)
{
var workflowDiff = totalDuration - avgTotalDuration;
var workflowDiffPercent = (workflowDiff / avgTotalDuration) * 100;
_logger.LogWarning("Workflow {Workflow} took significantly longer than average: {TotalDuration:F2}s (avg: {Avg:F2}s, {Diff:+#.##}s, {DiffPercent:+#0.#}% slower) for {Suburb}, {State}",
Name, totalDuration, avgTotalDuration, workflowDiff, workflowDiffPercent, context.Suburb, context.State);
}
else
{
_logger.LogInformation("Workflow {Workflow} completed in {TotalDuration:F2}s for {Suburb}, {State}. Step breakdown: {StepBreakdown}",
Name, totalDuration, context.Suburb, context.State, stepBreakdown);
}
// Log step performance summary if we have historical data
var stepMetrics = _cacheService.GetStepMetrics();
if (stepMetrics.Count > 0)
{
var slowSteps = stepTimings
.Where(t => stepMetrics.ContainsKey(t.stepName) && t.durationSeconds > stepMetrics[t.stepName] * 1.5)
.Select(t => $"{t.stepName} ({t.durationSeconds:F2}s vs avg {stepMetrics[t.stepName]:F2}s)")
.ToList();
if (slowSteps.Any())
{
_logger.LogWarning("Workflow {Workflow} had slower-than-average steps: {SlowSteps}",
Name, string.Join(", ", slowSteps));
}
}
return BuildResponse(context);
}
private bool ValidatePrerequisites(IScrapingStep step, ScrapingContext context)
{
return step.Prerequisites.All(prereq => context.CompletedSteps.Contains(prereq));
}
private RadarResponse BuildResponse(ScrapingContext context)
{
if (context.LastUpdatedInfo == null)
{
throw new InvalidOperationException("LastUpdatedInfo is required to build response");
}
if (context.Frames == null || context.Frames.Count == 0)
{
throw new InvalidOperationException("Frames are required to build response");
}
var cacheExpiresAt = context.LastUpdatedInfo.ObservationTime.AddMinutes(_cacheExpirationMinutes);
return ResponseBuilder.CreateRadarResponse(
context.CacheFolderPath,
context.Frames,
_cacheManagementCheckIntervalMinutes,
context.LastUpdatedInfo,
context.Suburb,
context.State,
cacheIsValid: true,
cacheExpiresAt: cacheExpiresAt,
isUpdating: false);
}
}
@@ -0,0 +1,41 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
namespace BomLocalService.Services.Scraping.Workflows;
/// <summary>
/// Future workflow for temperature map scraping (not yet implemented)
/// </summary>
public class TemperatureMapWorkflow : ITemperatureMapWorkflow
{
private readonly ILogger<TemperatureMapWorkflow> _logger;
public string Name => "TemperatureMap";
public string Description => "Scrapes temperature forecast map (future feature)";
public string[] StepNames => new[]
{
"NavigateHomepage",
"ClickSearchButton",
"FillSearchInput",
"WaitForSearchResults",
"SelectSearchResult",
// Future: "ClickTemperatureMapLink",
// Future: "WaitForMapReady",
// Future: "ExtractMetadata",
// Future: "CalculateMapBounds",
// Future: "CaptureTemperatureFrames"
};
public TemperatureMapWorkflow(ILogger<TemperatureMapWorkflow> logger)
{
_logger = logger;
}
public Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
throw new NotImplementedException("TemperatureMap workflow is not yet implemented");
}
}
+18 -859
View File
@@ -1,6 +1,6 @@
using BomLocalService.Models; using BomLocalService.Models;
using BomLocalService.Services.Interfaces; using BomLocalService.Services.Interfaces;
using BomLocalService.Utilities; using BomLocalService.Services.Scraping;
using Microsoft.Playwright; using Microsoft.Playwright;
namespace BomLocalService.Services; namespace BomLocalService.Services;
@@ -8,67 +8,16 @@ namespace BomLocalService.Services;
public class ScrapingService : IScrapingService public class ScrapingService : IScrapingService
{ {
private readonly ILogger<ScrapingService> _logger; private readonly ILogger<ScrapingService> _logger;
private readonly IBrowserService _browserService; private readonly IWorkflowFactory _workflowFactory;
private readonly ITimeParsingService _timeParsingService;
private readonly ICacheService _cacheService;
private readonly IDebugService _debugService;
private readonly int _dynamicContentWaitMs;
private readonly int _tileRenderWaitMs;
private readonly ScreenshotCropConfig _cropConfig;
// Selector constants
private static readonly string[] SearchButtonSelectors = new[]
{
"button[data-testid='searchLabel']",
"button[aria-label='Search for a location']",
"button.search-location__trigger-button",
"button.bom-button:has(span.bom-button__label:has-text('Search for a location'))",
"button:has-text('Search for a location')",
"[data-testid='showSearchModal'] button"
};
private static readonly string[] RadarLinkSelectors = new[]
{
"a.jump-link.bom-button.bom-button--secondary:has-text('Rain radar and weather map')",
".cta.button a.jump-link:has-text('Rain radar and weather map')",
"a.jump-link:has-text('Rain radar and weather map')",
".cta.button a:has-text('Rain radar and weather map')",
"a.bom-button--secondary:has-text('Rain radar and weather map')",
"a:has-text('Rain radar and weather map')",
"a:has-text('rain radar')",
"a[href*='radar']"
};
public ScrapingService( public ScrapingService(
ILogger<ScrapingService> logger, ILogger<ScrapingService> logger,
IBrowserService browserService, IWorkflowFactory workflowFactory)
ITimeParsingService timeParsingService,
ICacheService cacheService,
IDebugService debugService,
IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_browserService = browserService; _workflowFactory = workflowFactory;
_timeParsingService = timeParsingService;
_cacheService = cacheService;
_debugService = debugService;
_dynamicContentWaitMs = configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000);
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
// Load crop configuration
var cropSection = configuration.GetSection("Screenshot:Crop");
_cropConfig = new ScreenshotCropConfig
{
X = cropSection.GetValue<int>("X", 0),
Y = cropSection.GetValue<int>("Y", 0),
RightOffset = cropSection.GetValue<int>("RightOffset", 0),
Height = cropSection.GetValue<int?>("Height")
};
_logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, RightOffset={RightOffset}, Height={Height}",
_cropConfig.X, _cropConfig.Y, _cropConfig.RightOffset, _cropConfig.Height);
} }
/// <summary> /// <summary>
/// Scrapes the BOM website to get a radar screenshot for a location /// Scrapes the BOM website to get a radar screenshot for a location
/// </summary> /// </summary>
@@ -82,812 +31,22 @@ public class ScrapingService : IScrapingService
List<(string method, string url, int? status, string resourceType, DateTime timestamp)> networkRequests, List<(string method, string url, int? status, string resourceType, DateTime timestamp)> networkRequests,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try var context = new ScrapingContext
{ {
// Step 1: Navigate to BOM homepage and wait for search button Page = page,
_logger.LogInformation("Navigating to BOM homepage"); Suburb = suburb,
await page.GotoAsync("https://www.bom.gov.au/", new PageGotoOptions State = state,
{ CacheFolderPath = cacheFolderPath,
WaitUntil = WaitUntilState.DOMContentLoaded, DebugFolder = debugFolder,
Timeout = 30000 ConsoleMessages = consoleMessages,
}); NetworkRequests = networkRequests
// Wait for search button to be ready instead of NetworkIdle (faster)
var searchButtonReady = page.Locator("button[data-testid='searchLabel'], button[aria-label='Search for a location'], button.search-location__trigger-button").First;
await searchButtonReady.WaitForAsync(new LocatorWaitForOptions { Timeout = 10000, State = WaitForSelectorState.Visible });
await _debugService.SaveStepDebugAsync(debugFolder, 1, "homepage_loaded", page, consoleMessages, networkRequests, cancellationToken);
// Step 2: Click "Search for a location" button to open search UI
_logger.LogInformation("Clicking 'Search for a location' button");
var searchButton = await SelectorHelper.FindLocatorBySelectorsAsync(page, SearchButtonSelectors, _logger);
if (searchButton == null)
{
var errorMsg = "Could not find 'Search for a location' button on BOM homepage.";
await _debugService.SaveErrorDebugAsync(debugFolder, errorMsg, page, consoleMessages, networkRequests, cancellationToken);
throw new Exception(errorMsg);
}
await searchButton.ClickAsync();
// Wait for search input to appear
var searchInputReady = page.Locator("#search-enter-keyword").First;
await searchInputReady.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000, State = WaitForSelectorState.Visible });
await _debugService.SaveStepDebugAsync(debugFolder, 2, "search_button_clicked", page, consoleMessages, networkRequests, cancellationToken);
// Step 3: Find and fill search box with suburb name
_logger.LogInformation("Searching for suburb: {Suburb}", suburb);
var searchInput = page.Locator("#search-enter-keyword").First;
await searchInput.FillAsync(suburb);
// Step 4: Wait for autocomplete suggestions to appear
_logger.LogInformation("Waiting for autocomplete suggestions");
await page.WaitForFunctionAsync(@"() => {
const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=""listitem""]'));
return results.length > 0 && results.some(r => r.offsetParent !== null);
}", new PageWaitForFunctionOptions { Timeout = 10000 });
await _debugService.SaveStepDebugAsync(debugFolder, 3, "search_input_filled", page, consoleMessages, networkRequests, cancellationToken);
// Step 5: Find the matching search result based on suburb and state
_logger.LogInformation("Looking for matching search result for {Suburb}, {State}", suburb, state);
var suburbLower = suburb.ToLower().Trim();
var stateLower = state.ToLower().Trim();
// Get the actual count from the summary element
var summaryText = await page.Locator("#location-results-title, [data-testid='location-results-title']").First.TextContentAsync();
int? actualCount = null;
if (!string.IsNullOrEmpty(summaryText))
{
// Parse "3 of 3 location results" or similar
// Pattern has 2 capture groups: (\d+) of (\d+)
// Groups[0] = full match, Groups[1] = first number, Groups[2] = second number (total)
var countMatch = System.Text.RegularExpressions.Regex.Match(summaryText, @"(\d+)\s+of\s+(\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (countMatch.Success && countMatch.Groups.Count >= 3 && countMatch.Groups[2].Success)
{
if (int.TryParse(countMatch.Groups[2].Value, out var total))
{
actualCount = total;
}
}
}
// Fetch all result data in one JavaScript evaluation - extract location name and state separately
List<(string name, string desc, string fullText)> results = new();
try
{
var resultData = await page.EvaluateAsync<string[][]>(@"() => {
// Scope to the location results list - find the ul with aria-labelledby pointing to location-results-title
const resultsList = document.querySelector('ul[aria-labelledby=""location-results-title""]');
if (!resultsList) {
console.log('Location results list not found');
return [];
}
const results = Array.from(resultsList.querySelectorAll('li.bom-linklist__item[role=""listitem""]'));
console.log('Found', results.length, 'location results');
return results.map((r, index) => {
// Query from the li element - elements are nested inside <a> tag
const nameEl = r.querySelector('[data-testid=""location-name""]');
const descEl = r.querySelector('.bom-linklist-item__desc');
const name = nameEl ? (nameEl.textContent || nameEl.innerText || '').trim() : '';
const desc = descEl ? (descEl.textContent || descEl.innerText || '').trim() : '';
const fullText = (r.textContent || r.innerText || '').trim();
console.log('Result', index, ':', { hasNameEl: !!nameEl, hasDescEl: !!descEl, name: name, desc: desc });
return [name, desc, fullText];
});
}");
// Convert to structured data
results = resultData.Select(arr => (
name: arr.Length > 0 ? arr[0] : "",
desc: arr.Length > 1 ? arr[1] : "",
fullText: arr.Length > 2 ? arr[2] : ""
)).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract structured result data, falling back to text content");
// Fallback to simple text extraction
var resultTexts = await page.EvaluateAsync<string[]>(@"() => {
const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=""listitem""]'));
return results.map(r => r.textContent || '');
}");
results = resultTexts.Select(text => (name: "", desc: "", fullText: text)).ToList();
}
if (actualCount.HasValue)
{
_logger.LogInformation("Found {Count} location results (summary: {Summary})", actualCount.Value, summaryText?.Trim());
// Only use the first N results that match the actual count
if (results.Count > actualCount.Value)
{
results = results.Take(actualCount.Value).ToList();
}
}
else
{
_logger.LogInformation("Found {Count} search results", results.Count);
}
int? matchingIndex = null;
for (int i = 0; i < results.Count; i++)
{
var (name, desc, fullText) = results[i];
var nameLower = name.ToLower().Trim();
var descLower = desc.ToLower().Trim();
var fullTextLower = fullText.ToLower();
_logger.LogInformation("Checking result {Index}: Name='{Name}', Desc='{Desc}', FullText='{FullText}'",
i, name, desc, fullText.Length > 100 ? fullText.Substring(0, 100) + "..." : fullText);
// Check if suburb name matches (from location-name element or fallback to fullText)
var matchesSuburb = false;
if (!string.IsNullOrEmpty(name))
{
matchesSuburb = nameLower == suburbLower || nameLower.Contains(suburbLower);
}
else
{
// Fallback to fullText if name extraction failed
matchesSuburb = fullTextLower.Contains(suburbLower);
}
// Check if state matches (from description which contains "Queensland 4300" or "New South Wales 2469")
var matchesState = false;
if (!string.IsNullOrEmpty(desc))
{
matchesState = StateAbbreviationHelper.MatchesState(descLower, stateLower);
}
// Always check fullText for state as fallback
if (!matchesState)
{
matchesState = StateAbbreviationHelper.MatchesState(fullTextLower, stateLower);
}
_logger.LogInformation("Result {Index}: matchesSuburb={MatchesSuburb} (suburb='{Suburb}' vs name='{Name}'), matchesState={MatchesState} (state='{State}' vs desc='{Desc}')",
i, matchesSuburb, suburbLower, nameLower, matchesState, stateLower, descLower);
if (matchesSuburb && matchesState)
{
matchingIndex = i;
_logger.LogInformation("Found matching result: {Name} - {Desc}", name, desc);
break;
}
}
// Click the matching result (or first if no match)
// Scope to the location results list
var resultsList = page.Locator("ul[aria-labelledby='location-results-title']");
var allResults = resultsList.Locator("li.bom-linklist__item[role='listitem']");
var resultToClick = matchingIndex.HasValue ? allResults.Nth(matchingIndex.Value) : allResults.First;
if (!matchingIndex.HasValue)
{
_logger.LogInformation("No exact match found, using first result");
}
await resultToClick.ClickAsync();
await _debugService.SaveStepDebugAsync(debugFolder, 4, "search_result_selected", page, consoleMessages, networkRequests, cancellationToken);
// Wait for forecast page to load
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
await page.WaitForTimeoutAsync(_dynamicContentWaitMs); // Brief wait for dynamic content
await _debugService.SaveStepDebugAsync(debugFolder, 5, "forecast_page_loaded", page, consoleMessages, networkRequests, cancellationToken);
// Step 6: Find and click "Rain radar and weather map" link
_logger.LogInformation("Looking for 'Rain radar and weather map' link");
var radarLink = await SelectorHelper.FindLocatorBySelectorsAsync(page, RadarLinkSelectors, _logger);
if (radarLink == null)
{
var errorMsg = $"Could not find 'Rain radar and weather map' link for {suburb}, {state}";
await _debugService.SaveErrorDebugAsync(debugFolder, errorMsg, page, consoleMessages, networkRequests, cancellationToken);
throw new Exception(errorMsg);
}
await radarLink.ClickAsync();
await _debugService.SaveStepDebugAsync(debugFolder, 6, "radar_link_clicked", page, consoleMessages, networkRequests, cancellationToken);
// Step 7: Wait for weather map page to load and map to fully render
_logger.LogInformation("Waiting for weather map page to load");
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
// Wait for the map canvas element to appear with proper dimensions
_logger.LogInformation("Waiting for map canvas element to render");
var mapCanvas = page.Locator(".esri-view-surface canvas").First;
await mapCanvas.WaitForAsync(new LocatorWaitForOptions { Timeout = 15000 });
// Wait for canvas to have valid dimensions
await page.WaitForFunctionAsync(@"() => {
const canvas = document.querySelector('.esri-view-surface canvas');
return canvas && canvas.width > 0 && canvas.height > 0 && canvas.offsetWidth > 0 && canvas.offsetHeight > 0;
}", new PageWaitForFunctionOptions { Timeout = 15000 });
_logger.LogInformation("Map canvas is ready - waiting for map to render");
// Wait for Esri map view to be ready
try
{
await page.WaitForFunctionAsync(@"() => {
try {
const elements = document.querySelectorAll('.esri-view');
for (let el of elements) {
if (el.__view && el.__view.ready) {
return true;
}
}
} catch(e) {}
return false;
}", new PageWaitForFunctionOptions { Timeout = 30000 });
_logger.LogInformation("Esri map view is ready");
}
catch
{
_logger.LogInformation("Esri view ready check timed out, continuing with fixed wait");
}
// Additional wait for tiles to render
await page.WaitForTimeoutAsync(_tileRenderWaitMs);
await _debugService.SaveStepDebugAsync(debugFolder, 7, "weather_map_ready", page, consoleMessages, networkRequests, cancellationToken);
// Step 8: Ensure radar is paused before capturing frames
_logger.LogInformation("Checking if radar loop is paused");
var playPauseButton = page.Locator("button[data-testid='bom-time-scrub-play-pause']").First;
await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
// Check if button shows "Play" (paused) or "Pause" (playing)
var buttonLabel = await playPauseButton.Locator(".bom-scrub-action__label").TextContentAsync();
if (buttonLabel?.Trim().Equals("Pause", StringComparison.OrdinalIgnoreCase) == true)
{
_logger.LogInformation("Radar is playing, pausing it");
await playPauseButton.ClickAsync();
// Wait for pause to take effect
await page.WaitForTimeoutAsync(500);
// Verify it's now paused
buttonLabel = await playPauseButton.Locator(".bom-scrub-action__label").TextContentAsync();
if (buttonLabel?.Trim().Equals("Play", StringComparison.OrdinalIgnoreCase) != true)
{
_logger.LogWarning("Radar may not be paused after click, continuing anyway");
}
}
else
{
_logger.LogInformation("Radar is already paused");
}
await _debugService.SaveStepDebugAsync(debugFolder, 8, "radar_paused", page, consoleMessages, networkRequests, cancellationToken);
// Step 9: Click on first frame segment to ensure we start at frame 0
_logger.LogInformation("Resetting to first frame (frame 0)");
try
{
var firstFrameSegment = page.Locator("[data-testid='bom-scrub-segment'][data-id='0']").First;
await firstFrameSegment.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
await firstFrameSegment.ClickAsync();
// Wait for frame to update
await page.WaitForTimeoutAsync(1000);
_logger.LogInformation("Successfully clicked frame 0 segment");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to click first frame segment, continuing anyway");
}
await _debugService.SaveStepDebugAsync(debugFolder, 9, "frame_0_selected", page, consoleMessages, networkRequests, cancellationToken);
// Step 10: Verify scrubber is at position 0 before capturing first frame
_logger.LogInformation("Verifying scrubber is at position 0");
try
{
// Check if the active segment has data-id="0" (simpler and more reliable than checking thumb position)
var activeSegment = await page.EvaluateAsync<bool>(@"() => {
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
const activeSegment = segments.find(s => {
const style = window.getComputedStyle(s);
return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
});
return activeSegment && activeSegment.getAttribute('data-id') === '0';
}");
if (activeSegment)
{
_logger.LogInformation("Scrubber confirmed at position 0 (active segment is frame 0)");
}
else
{
_logger.LogDebug("Could not confirm scrubber position via active segment, but continuing (frame 0 was clicked)");
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Scrubber position verification failed, continuing anyway (non-critical check)");
}
await _debugService.SaveStepDebugAsync(debugFolder, 10, "scrubber_at_position_0", page, consoleMessages, networkRequests, cancellationToken);
// Step 11: Wait for frame 0 tiles to fully load before calculating bounding box
// This ensures the map viewport is stable and prevents jiggle between frames
_logger.LogInformation("Waiting for frame 0 tiles to fully render");
await page.WaitForTimeoutAsync(_tileRenderWaitMs);
// Step 12: Extract metadata and frame information
_logger.LogInformation("Extracting metadata and frame information");
var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(page);
var frameInfo = await ExtractFrameInfoAsync(page);
// Step 13: Get map container and calculate bounding box once
// Calculate after frame 0 tiles are loaded to ensure consistent viewport
_logger.LogInformation("Preparing map container for screenshot");
var mapContainer = page.Locator(".esri-view-surface").First;
await mapContainer.WaitForAsync(new LocatorWaitForOptions { Timeout = 10000 });
// Ensure the map container is visible and has dimensions
await page.WaitForFunctionAsync(@"() => {
const container = document.querySelector('.esri-view-surface');
return container && container.offsetWidth > 0 && container.offsetHeight > 0;
}", new PageWaitForFunctionOptions { Timeout = 10000 });
var boundingBox = await mapContainer.BoundingBoxAsync();
if (boundingBox == null)
{
throw new Exception("Could not determine map container bounds");
}
// Convert BoundingBox to Clip for crop calculation
var containerClip = new Clip
{
X = boundingBox.X,
Y = boundingBox.Y,
Width = boundingBox.Width,
Height = boundingBox.Height
};
// Step 14: Use provided cache folder (already created by BomRadarService)
Directory.CreateDirectory(cacheFolderPath);
_logger.LogInformation("Using cache folder: {Path}", cacheFolderPath);
// Step 15-21: Capture all 7 frames
var frames = new List<RadarFrame>();
var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First;
int? previousMinutesAgo = null;
for (int frameIndex = 0; frameIndex < 7; frameIndex++)
{
_logger.LogInformation("Capturing frame {FrameIndex} of 7", frameIndex);
// Wait for map to stabilize (tiles to load for current frame)
await page.WaitForTimeoutAsync(_tileRenderWaitMs);
// Extract actual minutes ago from the display label (e.g., "17 minutes ago" -> 17)
var minutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
if (minutesAgo == null)
{
// Fallback to calculated value if extraction fails
var (_, defaultMinutesAgo) = frameInfo[frameIndex];
minutesAgo = defaultMinutesAgo;
_logger.LogWarning("Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", frameIndex, minutesAgo);
}
// Validate that minutesAgo is different from previous frame (unless it's frame 0)
if (frameIndex > 0 && previousMinutesAgo.HasValue && minutesAgo == previousMinutesAgo.Value)
{
_logger.LogWarning("Frame {FrameIndex} has same minutesAgo ({MinutesAgo}) as previous frame. Waiting for display to update...", frameIndex, minutesAgo);
// Wait for display label to change
await WaitForDisplayLabelToChangeAsync(page, previousMinutesAgo.Value);
// Re-extract after waiting
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value)
{
// Still same or failed - use calculated fallback
var (_, defaultMinutesAgo) = frameInfo[frameIndex];
minutesAgo = defaultMinutesAgo;
_logger.LogWarning("Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", frameIndex, minutesAgo);
}
}
// Take screenshot with crop configuration
// Ensure radar subfolder exists
var radarFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, CachedDataType.Radar);
if (!Directory.Exists(radarFolder))
{
Directory.CreateDirectory(radarFolder);
}
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex);
await CaptureMapScreenshotAsync(page, mapContainer, framePath, containerClip);
frames.Add(new RadarFrame
{
FrameIndex = frameIndex,
ImagePath = framePath,
MinutesAgo = minutesAgo.Value
});
previousMinutesAgo = minutesAgo;
_logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
frameIndex, framePath, minutesAgo.Value);
// Save debug screenshot BEFORE clicking step forward
await _debugService.SaveStepDebugAsync(debugFolder, 15 + frameIndex, $"frame_{frameIndex}_captured", page, consoleMessages, networkRequests, cancellationToken);
// If not the last frame, click step forward to prepare for next frame
if (frameIndex < 6)
{
// Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before clicking
await DismissModalOverlaysAsync(page);
// Get current minutesAgo before clicking (to detect change)
var currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
// Use force click to bypass any remaining overlays
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
// Wait for display label to actually change (not just a fixed timeout)
if (currentMinutesAgo.HasValue)
{
await WaitForDisplayLabelToChangeAsync(page, currentMinutesAgo.Value);
}
else
{
// Fallback to fixed wait if we can't detect change
await page.WaitForTimeoutAsync(1000);
}
}
}
_logger.LogInformation("All 7 frames captured successfully");
// Step 22: Save metadata and frame information
await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken);
await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, frames, cancellationToken);
// Step 23: Return response with all frames
// Note: ScrapingService doesn't have access to cache management check interval
// Default to 5 minutes (standard check interval)
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: null, isUpdating: false, cacheManagementCheckIntervalMinutes: 5);
}
catch (Exception ex)
{
// Save error debug info if debug is enabled
await _debugService.SaveErrorDebugAsync(debugFolder, $"Exception: {ex.Message}\n\nStackTrace:\n{ex.StackTrace}", page, consoleMessages, networkRequests, cancellationToken);
_logger.LogError(ex, "Error during radar screenshot capture");
throw;
}
}
/// <summary>
/// Calculates the crop area for screenshot based on configuration
/// </summary>
private Clip CalculateCropArea(Clip containerClip)
{
// Start with container's position plus offset
var x = containerClip.X + _cropConfig.X;
var y = containerClip.Y + _cropConfig.Y;
// Calculate width: container width minus left offset (X) minus right offset
var width = Math.Max(0, containerClip.Width - _cropConfig.X - _cropConfig.RightOffset);
// Calculate height (use configured or remaining height)
var height = _cropConfig.Height ?? Math.Max(0, containerClip.Height - _cropConfig.Y);
// Validate bounds
if (x < containerClip.X || y < containerClip.Y)
{
_logger.LogWarning("Crop offset is outside container bounds, using container bounds");
x = containerClip.X;
y = containerClip.Y;
}
var maxWidth = containerClip.Width - (x - containerClip.X);
var maxHeight = containerClip.Height - (y - containerClip.Y);
if (width > maxWidth)
{
_logger.LogWarning("Crop width exceeds container bounds, adjusting from {Requested} to {Max}", width, maxWidth);
width = maxWidth;
}
if (height > maxHeight)
{
_logger.LogWarning("Crop height exceeds container bounds, adjusting from {Requested} to {Max}", height, maxHeight);
height = maxHeight;
}
if (width <= 0 || height <= 0)
{
throw new Exception($"Invalid crop dimensions: {width}x{height}");
}
_logger.LogDebug("Crop area calculated: X={X}, Y={Y}, Width={Width}, Height={Height} (container: {ContainerX}, {ContainerY}, {ContainerWidth}x{ContainerHeight})",
x, y, width, height, containerClip.X, containerClip.Y, containerClip.Width, containerClip.Height);
return new Clip
{
X = x,
Y = y,
Width = width,
Height = height
}; };
}
/// <summary>
/// Extracts frame information from timeline segments
/// </summary>
private async Task<List<(int index, int minutesAgo)>> ExtractFrameInfoAsync(IPage page)
{
try
{
var frameInfo = await page.EvaluateAsync<object[]>(@"() => {
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
return segments.map((seg, index) => {
const ariaLabel = seg.getAttribute('aria-label') || '';
// Extract minutes from '40 minutes ago', '35 minutes ago', etc.
const minutesMatch = ariaLabel.match(/(\d+)\s+minutes?\s+ago/);
const minutes = minutesMatch ? parseInt(minutesMatch[1]) : null;
return { index: index, minutesAgo: minutes };
});
}");
var result = new List<(int index, int minutesAgo)>();
for (int i = 0; i < 7; i++)
{
// Default values if extraction fails
var minutesAgo = 40 - (i * 5);
// Try to use extracted values if available
if (frameInfo != null && i < frameInfo.Length)
{
// The EvaluateAsync returns object[], we'd need to deserialize properly
// For now, use defaults
}
result.Add((i, minutesAgo));
}
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract frame info, using defaults");
// Return default frame info
return Enumerable.Range(0, 7)
.Select(i => (i, 40 - (i * 5)))
.ToList();
}
}
/// <summary>
/// Extracts the actual minutes ago value from the display label (e.g., "17 minutes ago" -> 17)
/// </summary>
private async Task<int?> ExtractMinutesAgoFromDisplayAsync(IPage page)
{
try
{
var timeLabel = await page.Locator(".bom-scrub-display-label").First.TextContentAsync();
if (string.IsNullOrEmpty(timeLabel))
{
return null;
}
// Parse "17 minutes ago" or "41 minutes ago" etc.
var match = System.Text.RegularExpressions.Regex.Match(
timeLabel.Trim(),
@"(\d+)\s+minutes?\s+ago",
System.Text.RegularExpressions.RegexOptions.IgnoreCase
);
if (match.Success && match.Groups.Count >= 2)
{
if (int.TryParse(match.Groups[1].Value, out var minutes))
{
return minutes;
}
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract minutes from display label");
return null;
}
}
/// <summary>
/// Waits for the display label to change from the current minutesAgo value
/// </summary>
private async Task WaitForDisplayLabelToChangeAsync(IPage page, int currentMinutesAgo, int maxWaitMs = 5000)
{
try
{
var startTime = DateTime.UtcNow;
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
{
var newMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
if (newMinutesAgo.HasValue && newMinutesAgo.Value != currentMinutesAgo)
{
// Display has changed
return;
}
await page.WaitForTimeoutAsync(200); // Check every 200ms
}
_logger.LogDebug("Display label did not change from {CurrentMinutesAgo} within {MaxWaitMs}ms", currentMinutesAgo, maxWaitMs);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error waiting for display label to change");
}
}
/// <summary>
/// Dismisses any modal overlays (BOM modals, reCAPTCHA, feedback forms) that might block the radar
/// Uses a single efficient check and minimal delays
/// </summary>
private async Task DismissModalOverlaysAsync(IPage page)
{
try
{
// Single check for all modal types (BOM, reCAPTCHA, feedback forms)
var hasModal = await page.EvaluateAsync<bool>(@"() => {
// Check BOM modal overlay
const bomOverlay = document.querySelector('.bom-modal-overlay--after-open');
if (bomOverlay && bomOverlay.style.display !== 'none') {
return true;
}
// Check reCAPTCHA modals (large visible elements)
const recaptchaSelectors = [
'.g-recaptcha',
'#g-recaptcha',
'.rc-anchor-container'
];
for (const selector of recaptchaSelectors) {
const el = document.querySelector(selector);
if (el) {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (style.display !== 'none' && style.visibility !== 'hidden' &&
rect.width > 200 && rect.height > 200) {
return true;
}
}
}
// Check feedback forms with reCAPTCHA
const allForms = document.querySelectorAll('form');
for (const form of allForms) {
const action = form.getAttribute('action') || '';
const id = form.getAttribute('id') || '';
if (action.includes('feedback') || id.includes('feedback')) {
const style = window.getComputedStyle(form);
const rect = form.getBoundingClientRect();
if (style.display !== 'none' && rect.width > 200 && rect.height > 200) {
const text = form.textContent || '';
if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) {
return true;
}
}
}
}
return false;
}");
if (!hasModal)
{
return; // No modal, exit quickly
}
_logger.LogDebug("Modal overlay detected, dismissing");
// Single Escape key press (works for most modals)
await page.Keyboard.PressAsync("Escape");
await page.WaitForTimeoutAsync(300); // Minimal delay
// If still visible, try clicking outside (on map)
var stillVisible = await page.EvaluateAsync<bool>(@"() => {
const bomOverlay = document.querySelector('.bom-modal-overlay--after-open');
if (bomOverlay && bomOverlay.style.display !== 'none') return true;
// Check feedback forms
const allForms = document.querySelectorAll('form');
for (const form of allForms) {
const action = form.getAttribute('action') || '';
const id = form.getAttribute('id') || '';
if (action.includes('feedback') || id.includes('feedback')) {
const style = window.getComputedStyle(form);
const rect = form.getBoundingClientRect();
if (style.display !== 'none' && rect.width > 200 && rect.height > 200) {
return true;
}
}
}
return false;
}");
if (stillVisible)
{
// Click on map to dismiss (only if Escape didn't work)
try
{
var mapContainer = page.Locator(".esri-view-surface").First;
await mapContainer.ClickAsync(new LocatorClickOptions { Force = true });
await page.WaitForTimeoutAsync(200); // Minimal delay
}
catch
{
// Ignore if click fails
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error dismissing modal overlay, continuing");
}
}
/// <summary>
/// Captures map screenshot with crop configuration
/// </summary>
private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip)
{
Clip cropArea;
try
{
cropArea = CalculateCropArea(containerClip);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to calculate crop area, using full container bounds. Container: X={X}, Y={Y}, Width={Width}, Height={Height}",
containerClip.X, containerClip.Y, containerClip.Width, containerClip.Height);
// Fallback to full container if crop calculation fails
cropArea = containerClip;
}
// Validate crop area is within page bounds before attempting screenshot // Get the appropriate workflow (currently only RadarScraping is implemented)
var viewportSize = page.ViewportSize; var workflow = _workflowFactory.GetWorkflow<RadarResponse>("RadarScraping");
if (viewportSize == null || cropArea.X < 0 || cropArea.Y < 0 ||
cropArea.X + cropArea.Width > viewportSize.Width ||
cropArea.Y + cropArea.Height > viewportSize.Height)
{
_logger.LogWarning("Crop area is outside viewport bounds, using full container. Crop: X={X}, Y={Y}, Width={Width}, Height={Height}, Viewport: {ViewportWidth}x{ViewportHeight}",
cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height, viewportSize?.Width ?? 0, viewportSize?.Height ?? 0);
cropArea = containerClip;
}
// Final validation - ensure dimensions are positive _logger.LogInformation("Executing {Workflow} workflow for {Suburb}, {State}", workflow.Name, suburb, state);
if (cropArea.Width <= 0 || cropArea.Height <= 0)
{
_logger.LogError("Invalid crop dimensions: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height);
cropArea = containerClip;
}
// Wait for fonts to be loaded to prevent text rendering artifacts return await workflow.ExecuteAsync(context, cancellationToken);
try
{
await page.WaitForLoadStateAsync(LoadState.NetworkIdle, new PageWaitForLoadStateOptions { Timeout = 5000 });
}
catch
{
// Continue if network idle timeout - fonts may already be loaded
}
// Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before taking screenshot
await DismissModalOverlaysAsync(page);
// Take high-quality screenshot with explicit PNG format and disabled animations
await page.ScreenshotAsync(new PageScreenshotOptions
{
Path = outputPath,
Clip = cropArea,
Type = ScreenshotType.Png, // Explicit PNG for lossless quality
Animations = ScreenshotAnimations.Disabled // Disable animations to prevent artifacts
});
_logger.LogDebug("Screenshot saved: {Path} (crop: {X},{Y} {Width}x{Height})",
outputPath, cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height);
} }
} }
+68
View File
@@ -0,0 +1,68 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using Microsoft.Playwright;
namespace BomLocalService.Services;
public class SelectorService : ISelectorService
{
private readonly ILogger<SelectorService> _logger;
public SelectorService(ILogger<SelectorService> logger)
{
_logger = logger;
}
public async Task<ILocator?> FindElementAsync(IPage page, SelectorConfig config, CancellationToken cancellationToken = default)
{
foreach (var selector in config.Selectors)
{
try
{
var locator = page.Locator(selector).First;
if (await locator.IsVisibleAsync())
{
_logger.LogInformation("Found {Name} with selector: {Selector}", config.Name, selector);
return locator;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Selector {Selector} not found or not visible for {Name}", selector, config.Name);
continue;
}
}
if (config.Required)
{
var errorMsg = config.ErrorMessage ?? $"Required element {config.Name} not found with any selector";
_logger.LogWarning("{Message}", errorMsg);
}
else
{
_logger.LogDebug("Optional element {Name} not found with any selector", config.Name);
}
return null;
}
public ILocator GetLocator(IPage page, SelectorConfig config)
{
if (config.Selectors.Length == 0)
{
throw new ArgumentException($"No selectors configured for {config.Name}");
}
// Match any configured selector (BOM occasionally renames test ids; list is tried in order)
var combined = page.Locator(config.Selectors[0]).First;
for (var i = 1; i < config.Selectors.Length; i++)
{
combined = combined.Or(page.Locator(config.Selectors[i]).First);
}
// Or() unions alternatives; BOM sometimes renders duplicates (e.g. responsive + hidden).
// Pin to a single element so Playwright strict mode does not reject clicks.
return combined.First;
}
}
+83 -68
View File
@@ -14,7 +14,13 @@ public class TimeParsingService : ITimeParsingService
public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration) public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
var timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane"; // Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
var timezone = configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
{
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
}
try try
{ {
@@ -23,8 +29,8 @@ public class TimeParsingService : ITimeParsingService
} }
catch (TimeZoneNotFoundException) catch (TimeZoneNotFoundException)
{ {
_logger.LogWarning("Timezone {Timezone} not found, defaulting to Australia/Brisbane", timezone); _logger.LogError("Timezone {Timezone} not found. Please use a valid IANA timezone identifier.", timezone);
_timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane"); throw new InvalidOperationException($"Invalid timezone '{timezone}'. Please use a valid IANA timezone identifier (e.g., Australia/Sydney, Australia/Brisbane).");
} }
} }
@@ -35,59 +41,55 @@ public class TimeParsingService : ITimeParsingService
{ {
try try
{ {
// Look for the weather metadata section using the correct selector // BOM layouts: legacy map used inner divs; location/spatial pages often use C07_WeatherMetadata with
// HTML structure: <section data-testid="weatherMetadata"> with aria-label="Last updated" // a single section and visible text in the section (no div split). Prefer normalized innerText of the
var metadataSection = page.Locator("section[data-testid='weatherMetadata'], section[aria-label='Last updated']").First; // section so both shapes parse the same.
var metadataSection = page.Locator(
// Extract text from each div separately to preserve structure "section[data-testid='weatherMetadata'], " +
// Structure: <div>Observations: X minutes ago, 9:40 pm AEST</div><div>at Gympie...</div><div>Forecast: ...</div> "section[aria-label='Last updated'], " +
"section[data-component='C07_WeatherMetadata']").First;
var lastUpdatedText = await page.EvaluateAsync<string>(@"() => { var lastUpdatedText = await page.EvaluateAsync<string>(@"() => {
const section = document.querySelector('section[data-testid=""weatherMetadata""]') || const section =
document.querySelector('section[aria-label=""Last updated""]'); document.querySelector('section[data-testid=""weatherMetadata""]') ||
document.querySelector('section[aria-label=""Last updated""]') ||
document.querySelector('section[data-component=""C07_WeatherMetadata""]');
if (!section) return null; if (!section) return null;
const raw = section.innerText || section.textContent || '';
// Get all divs and join with newlines to preserve structure return raw.replace(/\s+/g, ' ').trim();
const divs = section.querySelectorAll('div');
return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' ');
}"); }");
if (string.IsNullOrEmpty(lastUpdatedText)) if (string.IsNullOrEmpty(lastUpdatedText))
{ {
// Fallback: get all text content var fallback = await metadataSection.InnerTextAsync();
lastUpdatedText = await metadataSection.TextContentAsync(); lastUpdatedText = string.IsNullOrEmpty(fallback)
? null
: Regex.Replace(fallback, @"\s+", " ").Trim();
} }
return ParseLastUpdatedText(lastUpdatedText ?? string.Empty); return ParseLastUpdatedText(lastUpdatedText ?? string.Empty);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to extract last updated information"); _logger.LogError(ex, "Failed to extract last updated information");
return new LastUpdatedInfo throw;
{
ObservationTime = DateTime.UtcNow,
ForecastTime = DateTime.UtcNow
};
} }
} }
/// <summary> /// <summary>
/// Parses last updated text to extract observation time, forecast time, weather station, and distance /// Parses last updated text to extract observation time, forecast time, weather station, and distance
/// Parses actual time strings to UTC timestamps - client calculates "minutes ago" dynamically
/// </summary> /// </summary>
public LastUpdatedInfo ParseLastUpdatedText(string text) public LastUpdatedInfo ParseLastUpdatedText(string text)
{ {
var info = new LastUpdatedInfo(); var info = new LastUpdatedInfo();
_logger.LogInformation("Parsing last updated text: {Text}", text); _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" // Parse observation time from time string (e.g., "9:40 pm AEST")
// And "Forecast: 41 minutes ago, 7:50 pm AEST" // Ignore relative prefix — BOM uses "a minute ago", "6 minutes ago", "an hour ago", "2 hours ago", etc.
// Prefer parsing the actual time string over "minutes ago" for accuracy var observationMatch = Regex.Match(text,
@"Observations:\s*(?:a\s+minute\s+ago|\d+\s*minutes?\s+ago|an\s+hour\s+ago|\d+\s*hours?\s+ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)",
// Parse observation time - improved regex to better capture time and timezone RegexOptions.IgnoreCase);
// 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) if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
{ {
var timeStr = observationMatch.Groups[1].Value.Trim(); var timeStr = observationMatch.Groups[1].Value.Trim();
@@ -99,30 +101,29 @@ public class TimeParsingService : ITimeParsingService
timezoneStr = timezoneStr.Substring(0, 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)) if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
{ {
info.ObservationTime = observationTime; info.ObservationTime = observationTime;
_logger.LogInformation("Successfully parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})", _logger.LogInformation("Parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
observationTime, timeStr, timezoneStr ?? "default timezone"); observationTime, timeStr, timezoneStr ?? "default timezone");
} }
else else
{ {
// Fallback to "minutes ago" if time parsing fails _logger.LogError("Failed to parse observation time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
_logger.LogWarning("Failed to parse observation time from '{TimeStr}', falling back to 'minutes ago'", timeStr); throw new InvalidOperationException($"Failed to parse observation time from text: {text}");
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);
}
} }
} }
else
{
_logger.LogError("No observation time found in text: {Text}", text);
throw new InvalidOperationException($"Could not find observation time in text: {text}");
}
// Parse forecast time // Parse forecast time from time string (e.g., "7:50 pm AEST")
var forecastMatch = Regex.Match(text, @"Forecast:\s*(?:\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)", RegexOptions.IgnoreCase); // Ignore relative prefix (minutes/hours ago) — client derives "ago" from the UTC timestamp
var forecastMatch = Regex.Match(text,
@"Forecast:\s*(?:\d+\s*hours?\s+ago|an\s+hour\s+ago|\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)",
RegexOptions.IgnoreCase);
if (forecastMatch.Success && forecastMatch.Groups[1].Success) if (forecastMatch.Success && forecastMatch.Groups[1].Success)
{ {
var timeStr = forecastMatch.Groups[1].Value.Trim(); var timeStr = forecastMatch.Groups[1].Value.Trim();
@@ -131,17 +132,20 @@ public class TimeParsingService : ITimeParsingService
if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime)) if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
{ {
info.ForecastTime = forecastTime; info.ForecastTime = forecastTime;
_logger.LogInformation("Parsed forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
forecastTime, timeStr, timezoneStr ?? "default timezone");
} }
else else
{ {
// Fallback to "minutes ago" if time parsing fails _logger.LogError("Failed to parse forecast time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
var minutesAgoMatch = Regex.Match(text, @"Forecast:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase); throw new InvalidOperationException($"Failed to parse forecast time from text: {text}");
if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
{
info.ForecastTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
}
} }
} }
else
{
_logger.LogError("No forecast time found in text: {Text}", text);
throw new InvalidOperationException($"Could not find forecast time in text: {text}");
}
// Extract weather station name // Extract weather station name
var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase); var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase);
@@ -194,20 +198,32 @@ public class TimeParsingService : ITimeParsingService
} }
} }
// Determine the timezone - use configured timezone if not specified or if it's AEST/AEDT // Determine the timezone - map AEST/AEDT based on what the BOM website displays
// Note: The BOM website shows the timezone abbreviation for the location being viewed,
// NOT the browser's configured timezone. The browser timezone only affects JavaScript Date operations.
TimeZoneInfo tz = _timeZoneInfo; TimeZoneInfo tz = _timeZoneInfo;
if (!string.IsNullOrEmpty(timezoneStr)) if (!string.IsNullOrEmpty(timezoneStr))
{ {
// AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney // Map timezone abbreviations to actual timezones:
// Both Brisbane and Sydney use AEST/AEDT // - "AEST" = UTC+10 (Australian Eastern Standard Time)
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) || // Used by Brisbane year-round, and by Sydney/Melbourne during winter
timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase)) // Since both are UTC+10 when showing AEST, we use Brisbane timezone (UTC+10 year-round)
// - "AEDT" = UTC+11 (Australian Eastern Daylight Time)
// Used by Sydney/Melbourne during summer (Oct-Apr), never Brisbane
// We use Sydney timezone which handles the UTC+11 offset correctly
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase))
{ {
tz = _timeZoneInfo; // Use configured timezone // AEST maps to Brisbane timezone (UTC+10) - works for both Brisbane and Sydney in winter
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
}
else if (timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
{
// AEDT maps to Sydney timezone (UTC+11) - only appears in summer for Sydney/Melbourne
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
} }
else else
{ {
// Try to find the timezone, fallback to configured // Try to find the timezone by name, fallback to configured browser timezone
try try
{ {
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr); tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
@@ -223,18 +239,17 @@ public class TimeParsingService : ITimeParsingService
var nowInTz = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); var nowInTz = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay); 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), // Only adjust to yesterday if the time is clearly in the future
// or if it's more than 12 hours in the past, assume it's from yesterday // This ensures we get the correct date for the observation/forecast time
if (dateTimeInTz > nowInTz) if (dateTimeInTz > nowInTz)
{ {
// Time is in the future, so it must be from yesterday // Time is in the future, so it must be from yesterday
dateTimeInTz = dateTimeInTz.AddDays(-1); dateTimeInTz = dateTimeInTz.AddDays(-1);
_logger.LogDebug("Adjusted date to yesterday because parsed time {ParsedTime} is in the future (current: {CurrentTime})",
dateTimeInTz, nowInTz);
} }
else if ((nowInTz - dateTimeInTz).TotalHours > 12) // Removed the 12-hour threshold check - it was causing incorrect date assignments
{ // If we're using this fallback, we accept the date as-is (today) unless it's clearly in the future
// More than 12 hours ago, likely from yesterday
dateTimeInTz = dateTimeInTz.AddDays(-1);
}
// Convert to UTC // Convert to UTC
// Note: Brisbane (Australia/Brisbane) is always UTC+10 (AEST), no daylight saving // Note: Brisbane (Australia/Brisbane) is always UTC+10 (AEST), no daylight saving
+41 -2
View File
@@ -14,8 +14,12 @@ public static class CacheHelper
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType) public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
{ {
var dataTypeName = dataType.ToString(); var dataTypeName = dataType.ToString();
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7); var frameCountConfig = configuration.GetValue<int?>($"CachedDataTypes:{dataTypeName}:FrameCount");
return frameCount; if (!frameCountConfig.HasValue)
{
throw new InvalidOperationException($"CachedDataTypes:{dataTypeName}:FrameCount configuration is required. Set it in appsettings.json or via CACHEDDATATYPES__{dataTypeName.ToUpperInvariant()}__FRAMECOUNT environment variable.");
}
return frameCountConfig.Value;
} }
/// <summary> /// <summary>
@@ -61,5 +65,40 @@ public static class CacheHelper
{ {
return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration); return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration);
} }
/// <summary>
/// Calculates the estimated cache update duration in seconds.
/// This is used as a fallback when metrics-based estimation is not available yet.
/// Calculates based on frame count and configured wait times.
/// </summary>
public static int GetEstimatedUpdateDurationSeconds(IConfiguration configuration, CachedDataType dataType = CachedDataType.Radar)
{
// Calculate based on actual wait times and frame count
var frameCount = GetFrameCountForDataType(configuration, dataType);
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
var tileRenderWaitMs = tileRenderWaitMsConfig.Value;
var dynamicContentWaitMsConfig = configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
if (!dynamicContentWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
}
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
// Rough calculation:
// - Initial page load and navigation: ~10-15 seconds
// - Per frame: tileRenderWaitMs (default 5s) + overhead (~1-2s for clicking, waiting, etc.)
// - Final processing and metadata saving: ~5 seconds
var perFrameSeconds = (tileRenderWaitMs + 1500) / 1000.0; // Add 1.5s overhead per frame
var baseOverheadSeconds = 15; // Initial load + final processing
var estimatedSeconds = (int)Math.Ceiling(baseOverheadSeconds + (frameCount * perFrameSeconds));
return estimatedSeconds;
}
} }
+3 -3
View File
@@ -5,12 +5,12 @@ namespace BomLocalService.Utilities;
public static class FilePathHelper public static class FilePathHelper
{ {
/// <summary> /// <summary>
/// Gets the cache directory path from configuration or returns default /// Gets the cache directory path from configuration (default from appsettings.json, can be overridden via CACHEDIRECTORY environment variable)
/// </summary> /// </summary>
public static string GetCacheDirectory(IConfiguration configuration) public static string GetCacheDirectory(IConfiguration configuration)
{ {
return configuration.GetValue<string>("CacheDirectory") return configuration.GetValue<string>("CacheDirectory")
?? Path.Combine(AppContext.BaseDirectory, "cache"); ?? throw new InvalidOperationException("CacheDirectory configuration is required. Set it in appsettings.json or via CACHEDIRECTORY environment variable.");
} }
/// <summary> /// <summary>
+14 -4
View File
@@ -22,7 +22,8 @@ public static class LocationHelper
} }
/// <summary> /// <summary>
/// Parses suburb and state from a cache filename (format: Suburb_State_YYYYMMDD_HHMMSS.png) /// Parses suburb and state from a cache filename (format: Suburb_State_YYYYMMDD_HHMMSS)
/// Handles multi-word suburbs like "Gold Coast" which becomes "Gold_Coast_QLD_20251216_130831"
/// Returns null if parsing fails /// Returns null if parsing fails
/// </summary> /// </summary>
public static (string suburb, string state)? ParseLocationFromFilename(string fileName) public static (string suburb, string state)? ParseLocationFromFilename(string fileName)
@@ -30,12 +31,21 @@ public static class LocationHelper
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var parts = fileNameWithoutExtension.Split('_'); var parts = fileNameWithoutExtension.Split('_');
if (parts.Length >= 2) // Need at least: suburb, state, date, time (4 parts minimum)
// Format: [Suburb_Parts...]_State_YYYYMMDD_HHMMSS
if (parts.Length < 4)
{ {
return (parts[0], parts[1]); return null;
} }
return null; // Last two parts are always timestamp (YYYYMMDD, HHMMSS)
// Second-to-last part is the state
// Everything before that is the suburb (may contain underscores from multi-word suburbs)
var state = parts[^3]; // Third from end
var suburbParts = parts.Take(parts.Length - 3).ToArray();
var suburb = string.Join(" ", suburbParts); // Join with spaces (original format had spaces converted to underscores)
return (suburb, state);
} }
/// <summary> /// <summary>
+36 -5
View File
@@ -4,20 +4,48 @@ namespace BomLocalService.Utilities;
public static class ResponseBuilder public static class ResponseBuilder
{ {
/// <summary>
/// Calculates the next time the background cache management service will check for updates.
/// Rounds up to the next interval boundary within the current hour, or wraps to next hour if needed.
/// </summary>
public static DateTime CalculateNextServiceCheck(int checkIntervalMinutes)
{
var now = DateTime.UtcNow;
var currentMinute = now.Minute;
// Calculate how many complete intervals have elapsed this hour
var intervalsElapsed = currentMinute / checkIntervalMinutes;
// Next interval starts at (intervalsElapsed + 1) * checkIntervalMinutes
var nextIntervalStartMinute = (intervalsElapsed + 1) * checkIntervalMinutes;
// If next interval is beyond 60 minutes, wrap to next hour
if (nextIntervalStartMinute >= 60)
{
// Next check is at the start of next hour (00 minutes)
return now.Date.AddHours(now.Hour + 1).AddMinutes(0);
}
// Next check is within current hour
return now.Date.AddHours(now.Hour).AddMinutes(nextIntervalStartMinute);
}
/// <summary> /// <summary>
/// Creates a RadarResponse from a cache folder path, frames, and metadata /// Creates a RadarResponse from a cache folder path, frames, and metadata
/// </summary> /// </summary>
/// <param name="cacheManagementCheckIntervalMinutes">The interval in minutes that the background cache management service checks for updates. Used to calculate NextUpdateTime when cache is invalid.</param> /// <param name="cacheManagementCheckIntervalMinutes">The interval in minutes that the background cache management service checks for updates. Used to calculate NextUpdateTime when cache is invalid.</param>
/// <param name="estimatedUpdateDurationSeconds">Estimated duration in seconds for a cache update to complete. Used to calculate NextUpdateTime when update is in progress.</param>
public static RadarResponse CreateRadarResponse( public static RadarResponse CreateRadarResponse(
string cacheFolderPath, string cacheFolderPath,
List<RadarFrame> frames, List<RadarFrame> frames,
int cacheManagementCheckIntervalMinutes,
LastUpdatedInfo? metadata = null, LastUpdatedInfo? metadata = null,
string? suburb = null, string? suburb = null,
string? state = null, string? state = null,
bool? cacheIsValid = null, bool? cacheIsValid = null,
DateTime? cacheExpiresAt = null, DateTime? cacheExpiresAt = null,
bool isUpdating = false, bool isUpdating = false,
int cacheManagementCheckIntervalMinutes = 5) int? estimatedUpdateDurationSeconds = null)
{ {
var folderInfo = new DirectoryInfo(cacheFolderPath); var folderInfo = new DirectoryInfo(cacheFolderPath);
var lastWriteTime = folderInfo.Exists var lastWriteTime = folderInfo.Exists
@@ -35,6 +63,9 @@ public static class ResponseBuilder
} }
} }
// AbsoluteObservationTime is already set during capture, no calculation needed
// This ensures consistency with the time series endpoint
// Calculate NextUpdateTime based on cache status: // Calculate NextUpdateTime based on cache status:
// - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check) // - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check)
// (Background service checks every N minutes, so update might happen at next check after expiry) // (Background service checks every N minutes, so update might happen at next check after expiry)
@@ -44,13 +75,13 @@ public static class ResponseBuilder
// Calculate next background service check time (rounds up to next check interval) // Calculate next background service check time (rounds up to next check interval)
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes); var nextServiceCheck = CalculateNextServiceCheck(cacheManagementCheckIntervalMinutes);
var nextServiceCheck = now.AddMinutes(minutesUntilNextCheck);
if (isUpdating) if (isUpdating)
{ {
// Update in progress - estimate completion in ~2 minutes // Update in progress - estimate completion based on configured/calculated duration
nextUpdateTime = now.AddMinutes(2); var durationSeconds = estimatedUpdateDurationSeconds ?? 120; // Default to 2 minutes if not provided
nextUpdateTime = now.AddSeconds(durationSeconds);
} }
else if (cacheIsValid == true && cacheExpiresAt.HasValue) else if (cacheIsValid == true && cacheExpiresAt.HasValue)
{ {
File diff suppressed because it is too large Load Diff
+212 -3
View File
@@ -6,21 +6,26 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Cors": {
"AllowedOrigins": "*",
"AllowedMethods": "GET,POST,OPTIONS",
"AllowedHeaders": "*",
"AllowCredentials": false
},
"CacheDirectory": "./cache", "CacheDirectory": "./cache",
"CacheRetentionHours": 24, "CacheRetentionHours": 24,
"CacheExpirationMinutes": 12.5, "CacheExpirationMinutes": 12.5,
"CacheManagement": { "CacheManagement": {
"CheckIntervalMinutes": 5, "CheckIntervalMinutes": 5,
"InitialDelaySeconds": 10, "InitialDelaySeconds": 10,
"UpdateStaggerSeconds": 2,
"LocationStaggerSeconds": 1 "LocationStaggerSeconds": 1
}, },
"CacheCleanup": { "CacheCleanup": {
"IntervalHours": 1 "IntervalHours": 1
}, },
"Screenshot": { "Screenshot": {
"DynamicContentWaitMs": 2000, "DynamicContentWaitMs": 1500,
"TileRenderWaitMs": 5000, "TileRenderWaitMs": 3000,
"Crop": { "Crop": {
"X": 250, "X": 250,
"Y": 0, "Y": 0,
@@ -37,5 +42,209 @@
"Radar": { "Radar": {
"FrameCount": 7 "FrameCount": 7
} }
},
"TimeSeries": {
"WarningFolderCount": 200,
"MaxTimeRangeHours": null
},
"Scraping": {
"BaseUrl": "https://www.bom.gov.au/",
"Timeouts": {
"PageNavigation": 30000,
"ElementWait": 10000,
"SearchResults": 10000,
"MapRender": 15000,
"TileRender": 5000
},
"Selectors": {
"SearchButton": {
"Name": "Search Button",
"Selectors": [
"button[data-testid='searchLabel']",
"button.search-location__trigger-button",
"button[aria-label='Search for a location'].search-location__trigger-button",
"button.search-location__trigger-button.bom-button:has(span.bom-button__label:has-text('Search for a location'))"
],
"TimeoutMs": 10000,
"Required": true,
"ErrorMessage": "Could not find 'Search for a location' button on BOM homepage."
},
"SearchInput": {
"Name": "Search Input",
"Selectors": ["#search-enter-keyword"],
"TimeoutMs": 5000,
"Required": true
},
"SearchResultsList": {
"Name": "Search Results List",
"Selectors": ["ul[aria-labelledby='location-results-title']"],
"TimeoutMs": 10000,
"Required": true
},
"SearchResultItem": {
"Name": "Search Result Item",
"Selectors": ["li.bom-linklist__item[role='listitem']"],
"TimeoutMs": 5000,
"Required": true
},
"LocationName": {
"Name": "Location Name",
"Selectors": ["[data-testid='location-name']"],
"TimeoutMs": 5000,
"Required": false
},
"LocationDescription": {
"Name": "Location Description",
"Selectors": [".bom-linklist-item__desc"],
"TimeoutMs": 5000,
"Required": false
},
"ResultsTitle": {
"Name": "Results Title",
"Selectors": ["#location-results-title", "[data-testid='location-results-title']"],
"TimeoutMs": 5000,
"Required": false
},
"RadarLink": {
"Name": "Radar Link",
"Selectors": [
"a.jump-link.bom-button.bom-button--secondary:has-text('Rain radar and weather map')",
".cta.button a.jump-link:has-text('Rain radar and weather map')",
"a.jump-link:has-text('Rain radar and weather map')",
".cta.button a:has-text('Rain radar and weather map')",
"a.bom-button--secondary:has-text('Rain radar and weather map')",
"a:has-text('Rain radar and weather map')",
"a:has-text('rain radar')",
"a[href*='radar']"
],
"TimeoutMs": 10000,
"Required": true,
"ErrorMessage": "Could not find 'Rain radar and weather map' link"
},
"MapCanvas": {
"Name": "Map Canvas",
"Selectors": [".esri-view-surface canvas"],
"TimeoutMs": 15000,
"Required": true
},
"MapContainer": {
"Name": "Map Container",
"Selectors": [".esri-view-surface"],
"TimeoutMs": 10000,
"Required": true
},
"PlayPauseButton": {
"Name": "Play Pause Button",
"Selectors": [
"button[data-testid='bom-time-scrub-step-through']",
"button[data-testid='bom-time-scrub-play-pause']"
],
"TimeoutMs": 5000,
"Required": true
},
"PlayPauseLabel": {
"Name": "Play Pause Label",
"Selectors": [".bom-scrub-action__label"],
"TimeoutMs": 2000,
"Required": false
},
"FrameSegment": {
"Name": "Frame Segment",
"Selectors": ["[data-testid='bom-scrub-segment']"],
"TimeoutMs": 5000,
"Required": false
},
"StepForwardButton": {
"Name": "Step Forward Button",
"Selectors": [
"button[data-testid='bom-time-scrub-step-through']",
"button.bom-scrub-action--step-through",
"button[data-testid='bom-scrub-utils__right__step-forward']"
],
"TimeoutMs": 5000,
"Required": true
},
"TimeDisplayLabel": {
"Name": "Time Display Label",
"Selectors": [".bom-scrub-display-label"],
"TimeoutMs": 2000,
"Required": false
},
"ModalOverlay": {
"Name": "Modal Overlay",
"Selectors": [".bom-modal-overlay--after-open"],
"TimeoutMs": 1000,
"Required": false
},
"WeatherMetadata": {
"Name": "Weather Metadata",
"Selectors": ["section[data-testid='weatherMetadata']", "section[aria-label='Last updated']"],
"TimeoutMs": 5000,
"Required": false
}
},
"JavaScriptTemplates": {
"WaitForSearchResults": "() => { const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=\"listitem\"]')); return results.length > 0 && results.some(r => r.offsetParent !== null); }",
"ExtractSearchResults": "() => { const resultsList = document.querySelector('ul[aria-labelledby=\"location-results-title\"]'); if (!resultsList) { console.log('Location results list not found'); return []; } const results = Array.from(resultsList.querySelectorAll('li.bom-linklist__item[role=\"listitem\"]')); console.log('Found', results.length, 'location results'); return results.map((r) => { const nameEl = r.querySelector('[data-testid=\"location-name\"]'); const descEl = r.querySelector('.bom-linklist-item__desc'); const name = nameEl ? (nameEl.textContent || nameEl.innerText || '').trim() : ''; const desc = descEl ? (descEl.textContent || descEl.innerText || '').trim() : ''; const fullText = (r.textContent || r.innerText || '').trim(); console.log('Result:', { hasNameEl: !!nameEl, hasDescEl: !!descEl, name: name, desc: desc }); return [name, desc, fullText]; }); }",
"ExtractSearchResultsFallback": "() => { const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=\"listitem\"]')); return results.map(r => r.textContent || ''); }",
"WaitForMapCanvas": "() => { const canvas = document.querySelector('.esri-view-surface canvas'); return canvas && canvas.width > 0 && canvas.height > 0 && canvas.offsetWidth > 0 && canvas.offsetHeight > 0; }",
"WaitForEsriView": "() => { try { const elements = document.querySelectorAll('.esri-view'); for (let el of elements) { if (el.__view && el.__view.ready) { return true; } } } catch(e) {} return false; }",
"CheckActiveFrameSegment": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); if (segments.length === 0) return true; const activeSegment = segments.find(s => { const style = window.getComputedStyle(s); return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)'; }); return activeSegment && activeSegment.getAttribute('data-id') === '0'; }",
"WaitForMapContainer": "() => { const container = document.querySelector('.esri-view-surface'); return container && container.offsetWidth > 0 && container.offsetHeight > 0; }",
"ExtractFrameInfo": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const now = new Date(); return segments.map((seg, index) => { const ariaLabel = seg.getAttribute('aria-label') || ''; let minutes = null; const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\\s+)?(\\d{1,2})\\s+([A-Za-z]+),?\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)/i); if (timestampMatch) { try { const day = parseInt(timestampMatch[1]); const monthStr = timestampMatch[2]; const hour12 = parseInt(timestampMatch[3]); const minute = parseInt(timestampMatch[4]); const ampm = timestampMatch[5].toLowerCase(); const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = monthNames.indexOf(monthStr.toLowerCase()); if (month >= 0) { let hour24 = hour12; if (ampm === 'pm' && hour12 !== 12) hour24 += 12; if (ampm === 'am' && hour12 === 12) hour24 = 0; const year = now.getFullYear(); const frameTime = new Date(year, month, day, hour24, minute); if (frameTime > now) frameTime.setFullYear(year - 1); const diffMs = now - frameTime; minutes = Math.round(diffMs / (1000 * 60)); if (minutes < 0 || minutes > 120) minutes = null; } } catch(e) {} } return { index: index, minutesAgo: minutes }; }); }",
"CheckModalOverlay": "() => { const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); if (bomOverlay && bomOverlay.style.display !== 'none') { return true; } const recaptchaSelectors = ['.g-recaptcha', '#g-recaptcha', '.rc-anchor-container']; for (const selector of recaptchaSelectors) { const el = document.querySelector(selector); if (el) { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 200 && rect.height > 200) { return true; } } } const allForms = document.querySelectorAll('form'); for (const form of allForms) { const action = form.getAttribute('action') || ''; const id = form.getAttribute('id') || ''; if (action.includes('feedback') || id.includes('feedback')) { const style = window.getComputedStyle(form); const rect = form.getBoundingClientRect(); if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { const text = form.textContent || ''; if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) { return true; } } } } return false; }",
"CheckModalStillVisible": "() => { const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); if (bomOverlay && bomOverlay.style.display !== 'none') return true; const allForms = document.querySelectorAll('form'); for (const form of allForms) { const action = form.getAttribute('action') || ''; const id = form.getAttribute('id') || ''; if (action.includes('feedback') || id.includes('feedback')) { const style = window.getComputedStyle(form); const rect = form.getBoundingClientRect(); if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { return true; } } } return false; }",
"GetViewportSize": "() => JSON.stringify({ width: window.innerWidth, height: window.innerHeight })",
"ExtractWeatherMetadata": "() => { const section = document.querySelector('section[data-testid=\"weatherMetadata\"]') || document.querySelector('section[aria-label=\"Last updated\"]'); if (!section) return null; const divs = section.querySelectorAll('div'); return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' '); }"
},
"TextPatterns": {
"ResultsCountPattern": "(\\d+)\\s+of\\s+(\\d+)",
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]+,?\\s+\\d{1,2}:\\d{2}\\s*(?:am|pm)",
"ObservationTimePattern": "Observations:\\s*(\\d+)\\s*minutes?\\s*ago",
"ForecastTimePattern": "Forecast:\\s*(\\d+)\\s*minutes?\\s+ago",
"ForecastHourAgoPattern": "Forecast:\\s*an\\s+hour\\s+ago",
"WeatherStationPattern": "at\\s+([^,]+)\\s+weather\\s+station",
"DistancePattern": "(\\d+)\\s*km\\s+from",
"ExpectedTexts": {
"PlayButtonLabel": "Play",
"PauseButtonLabel": "Pause",
"RadarLinkText": "Rain radar and weather map"
}
},
"Workflows": {
"RadarScraping": {
"Description": "Scrapes radar images for a location",
"Steps": {
"NavigateHomepage": { "Enabled": true },
"ClickSearchButton": { "Enabled": true },
"FillSearchInput": { "Enabled": true },
"WaitForSearchResults": { "Enabled": true },
"SelectSearchResult": { "Enabled": true },
"ClickRadarLink": { "Enabled": true },
"WaitForMapReady": { "Enabled": true },
"PauseRadar": { "Enabled": true },
"ExtractMetadata": { "Enabled": true },
"ResetToFirstFrame": { "Enabled": true },
"CalculateMapBounds": { "Enabled": true },
"CaptureFrames": {
"Enabled": true,
"Parameters": {
"FrameCount": 7,
"WaitBetweenFrames": 5000
}
}
}
},
"TemperatureMap": {
"Description": "Scrapes temperature forecast map (future feature)",
"Steps": {
"NavigateHomepage": { "Enabled": true },
"ClickSearchButton": { "Enabled": true },
"FillSearchInput": { "Enabled": true },
"WaitForSearchResults": { "Enabled": true },
"SelectSearchResult": { "Enabled": true }
}
}
}
} }
} }
+8 -1
View File
@@ -17,6 +17,14 @@ services:
# Virtual display for non-headless browser # Virtual display for non-headless browser
- DISPLAY=:99 - DISPLAY=:99
# CORS Configuration (for Home Assistant integration)
# Multiple origins can be comma-separated: "http://homeassistant.local:8123,http://192.168.1.100:8123"
# Use "*" to allow all origins (less secure, but convenient for development)
- CORS__ALLOWEDORIGINS=${CORS__ALLOWEDORIGINS:-*}
- CORS__ALLOWEDMETHODS=${CORS__ALLOWEDMETHODS:-GET,POST,OPTIONS}
- CORS__ALLOWEDHEADERS=${CORS__ALLOWEDHEADERS:-*}
- CORS__ALLOWCREDENTIALS=${CORS__ALLOWCREDENTIALS:-false}
# Application configuration (override appsettings.json) # Application configuration (override appsettings.json)
# Use double underscore (__) for nested config keys # Use double underscore (__) for nested config keys
- CACHEDIRECTORY=${CACHEDIRECTORY:-/app/cache} - CACHEDIRECTORY=${CACHEDIRECTORY:-/app/cache}
@@ -24,7 +32,6 @@ services:
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5} - CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5} - CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10} - CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
- CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1} - CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1} - CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000} - SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
set -e
# Function to cleanup stale Xvfb processes and lock files
cleanup_xvfb() {
# Kill any stale Xvfb processes for display :99
pkill -f "Xvfb :99" 2>/dev/null || true
sleep 0.5
# Remove stale lock files
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true
}
# Function to check if Xvfb is actually functional
check_xvfb_working() {
# Check if process exists and is running (not zombie)
local pid=$(pgrep -f "Xvfb :99" | head -n1)
if [ -z "$pid" ]; then
return 1
fi
# Check if process is actually running (not a zombie)
if ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
# Function to start Xvfb
start_xvfb() {
# Clean up any stale processes/locks first
cleanup_xvfb
if ! check_xvfb_working; then
echo "Starting Xvfb on display :99..."
Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
sleep 1.5
if check_xvfb_working; then
echo "Xvfb started successfully"
return 0
else
echo "ERROR: Xvfb failed to start" >&2
return 1
fi
fi
return 0
}
# Function to monitor and restart Xvfb if it dies (runs as background process)
monitor_xvfb() {
local consecutive_failures=0
local max_backoff=60 # Maximum backoff in seconds
local backoff_seconds=5
# Monitor while PID 1 (dotnet after exec) is still running
while kill -0 1 2>/dev/null; do
sleep $backoff_seconds
if ! check_xvfb_working; then
consecutive_failures=$((consecutive_failures + 1))
if [ $consecutive_failures -eq 1 ]; then
echo "WARNING: Xvfb not working, attempting restart..." >&2
elif [ $consecutive_failures -le 3 ]; then
echo "WARNING: Xvfb restart attempt $consecutive_failures..." >&2
elif [ $((consecutive_failures % 10)) -eq 0 ]; then
echo "ERROR: Xvfb has failed to start $consecutive_failures times. Display may be unavailable." >&2
fi
if start_xvfb; then
consecutive_failures=0
backoff_seconds=5
else
# Exponential backoff, capped at max_backoff
backoff_seconds=$((backoff_seconds * 2))
if [ $backoff_seconds -gt $max_backoff ]; then
backoff_seconds=$max_backoff
fi
fi
else
# Xvfb is working, reset failure counter
if [ $consecutive_failures -gt 0 ]; then
echo "Xvfb is now working" >&2
consecutive_failures=0
backoff_seconds=5
fi
fi
done
}
# Cleanup function
cleanup() {
echo "Shutting down Xvfb..."
cleanup_xvfb
}
# Set up signal handlers for cleanup
trap cleanup SIGTERM SIGINT EXIT
# Start Xvfb
start_xvfb || exit 1
# Export DISPLAY
export DISPLAY=:99
# Start monitoring in background (will check if PID 1 is still running)
monitor_xvfb &
# Start the .NET application (replaces shell as PID 1)
exec dotnet BomLocalService.dll
+288
View File
@@ -0,0 +1,288 @@
.info-section {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}
@media (min-width: 480px) {
.info-section {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.info-section {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 20px;
}
}
.radar-section {
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}
@media (min-width: 768px) {
.radar-section {
padding: 20px;
}
}
.radar-image-container {
position: relative;
width: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
aspect-ratio: 16/9;
min-height: 250px; /* Smaller on mobile */
}
@media (min-width: 768px) {
.radar-image-container {
margin-bottom: 20px;
min-height: 400px;
}
}
.radar-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
image-rendering: crisp-edges; /* Prevent blurry scaling */
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.2em;
}
.frame-controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 15px;
}
@media (min-width: 768px) {
.frame-controls {
gap: 10px;
margin-bottom: 20px;
}
}
.frame-btn {
padding: 10px 20px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
font-size: 0.9em;
}
.frame-btn:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-btn.active {
background: #667eea;
color: white;
}
.frame-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.play-controls {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 15px;
flex-wrap: wrap;
}
@media (min-width: 768px) {
.play-controls {
gap: 10px;
margin-bottom: 20px;
}
}
.play-btn {
padding: 12px 20px;
border: none;
background: #667eea;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.2s;
min-height: 44px; /* Touch target size */
flex: 1;
min-width: 100px;
white-space: nowrap;
}
@media (min-width: 480px) {
.play-btn {
flex: 0 1 auto;
min-width: auto;
}
}
@media (min-width: 768px) {
.play-btn {
padding: 12px 30px;
font-size: 1em;
}
}
.play-btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-card {
background: white;
border-radius: 8px;
padding: 12px;
border-left: 4px solid #667eea;
}
@media (min-width: 768px) {
.info-card {
padding: 15px;
}
}
.info-card h3 {
font-size: 0.8em;
text-transform: uppercase;
color: #666;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
@media (min-width: 768px) {
.info-card h3 {
font-size: 0.9em;
margin-bottom: 8px;
}
}
.info-card .value {
font-size: 1em;
font-weight: 600;
color: #333;
word-wrap: break-word;
}
@media (min-width: 768px) {
.info-card .value {
font-size: 1.2em;
}
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
margin-top: 5px;
}
.status-valid {
background: #d4edda;
color: #155724;
}
.status-invalid {
background: #f8d7da;
color: #721c24;
}
.status-updating {
background: #fff3cd;
color: #856404;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 8px;
margin: 20px;
border-left: 4px solid #dc3545;
animation: fadeIn 0.3s;
}
.error.retrying {
background: #fff3cd;
color: #856404;
border-left-color: #ffc107;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.timestamp {
font-size: 0.85em;
color: #666;
margin-top: 5px;
}
.refresh-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background: #28a745;
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.frame-info {
text-align: center;
color: #666;
font-size: 0.9em;
margin-top: 10px;
}
+275
View File
@@ -0,0 +1,275 @@
/* Frame Slider Controls */
.frame-slider-container {
width: 100%;
max-width: 100%;
padding: 15px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
border: 2px solid #e0e0e0;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
box-sizing: border-box;
margin-bottom: 15px;
}
@media (min-width: 768px) {
.frame-slider-container {
padding: 24px;
border-radius: 16px;
margin-bottom: 20px;
}
}
.frame-slider-wrapper {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
width: 100%;
flex-wrap: wrap;
}
@media (min-width: 480px) {
.frame-slider-wrapper {
gap: 6px;
flex-wrap: nowrap;
}
}
@media (min-width: 768px) {
.frame-slider-wrapper {
gap: 12px;
margin-bottom: 20px;
}
}
.frame-nav-btn {
padding: 10px 16px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
font-size: 0.9em;
transition: all 0.2s;
min-width: 50px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.frame-nav-btn:hover:not(:disabled) {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-nav-btn:active:not(:disabled) {
transform: translateY(0);
}
.frame-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f0f0f0;
border-color: #d0d0d0;
color: #999;
}
.frame-slider {
flex: 1;
min-width: 120px;
height: 10px;
border-radius: 5px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
max-width: 100%;
cursor: pointer;
transition: background 0.2s;
width: 100%;
order: 3; /* Put slider in middle on mobile when wrapped */
}
@media (min-width: 480px) {
.frame-slider {
min-width: 100px;
order: 0; /* Reset order for larger screens */
}
}
@media (min-width: 768px) {
.frame-slider {
min-width: 0;
}
}
.frame-slider:hover {
background: #d0d0d0;
}
/* Webkit (Chrome, Safari, Edge) */
.frame-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.2s;
border: 3px solid white;
}
.frame-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-webkit-slider-thumb:active {
transform: scale(1.05);
}
/* Track fill for webkit */
.frame-slider::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
/* Firefox */
.frame-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.2s;
}
.frame-slider::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-moz-range-thumb:active {
transform: scale(1.05);
}
.frame-slider::-moz-range-track {
height: 10px;
border-radius: 5px;
background: #e0e0e0;
}
.frame-slider::-moz-range-progress {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
.frame-info-display {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
}
@media (min-width: 480px) {
.frame-info-display {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding-top: 16px;
}
}
@media (min-width: 768px) {
.frame-info-display {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
.frame-info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
}
@media (min-width: 768px) {
.frame-info-item {
gap: 8px;
font-size: 0.95em;
}
}
.frame-info-label {
color: #667eea;
font-weight: 600;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 60px;
}
@media (min-width: 768px) {
.frame-info-label {
font-size: 0.9em;
min-width: 70px;
}
}
.frame-info-value {
color: #333;
font-weight: 700;
font-size: 0.95em;
}
@media (min-width: 768px) {
.frame-info-value {
font-size: 1.1em;
}
}
.frame-jump-input {
width: 70px;
padding: 10px 12px;
border: 2px solid #667eea;
border-radius: 8px;
font-size: 0.9em;
text-align: center;
font-weight: 700;
color: #333;
background: white;
transition: all 0.2s;
min-height: 44px; /* Touch target size */
box-sizing: border-box;
}
@media (min-width: 768px) {
.frame-jump-input {
padding: 8px 12px;
font-size: 1em;
min-height: auto;
}
}
.frame-jump-input:focus {
outline: none;
border-color: #764ba2;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
background: #f8f9ff;
}
.frame-jump-input::-webkit-inner-spin-button,
.frame-jump-input::-webkit-outer-spin-button {
opacity: 1;
height: 20px;
}
+122
View File
@@ -0,0 +1,122 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 10px;
color: #333;
}
@media (min-width: 768px) {
body {
padding: 20px;
}
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
@media (min-width: 768px) {
.header {
padding: 30px;
gap: 20px;
}
}
.header-content {
flex: 1;
text-align: center;
min-width: 0;
}
.header h1 {
font-size: 1.5em;
margin-bottom: 8px;
font-weight: 700;
word-wrap: break-word;
}
@media (min-width: 768px) {
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
}
.header .subtitle {
font-size: 0.9em;
opacity: 0.9;
}
@media (min-width: 768px) {
.header .subtitle {
font-size: 1.1em;
}
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.settings-header-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
min-height: 44px; /* Touch target size */
}
@media (min-width: 768px) {
.settings-header-btn {
padding: 12px 24px;
font-size: 1em;
gap: 8px;
}
}
.settings-header-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.content {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
}
@media (min-width: 768px) {
.content {
gap: 20px;
padding: 30px;
}
}
+6
View File
@@ -0,0 +1,6 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
+5
View File
@@ -0,0 +1,5 @@
@import url('./reset.css');
@import url('./layout.css');
@import url('./components.css');
@import url('./frame-slider.css');
+382
View File
@@ -0,0 +1,382 @@
// API client for fetching radar data
import { API_BASE } from './config.js';
import { formatDate, isNetworkError, isNetworkErrorResponse, createCacheStatusData, triggerBackgroundRefresh } from './utils.js';
import { state } from './state.js';
/**
* Fetch cache range information
*/
export async function fetchCacheRange() {
try {
const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
if (isNetworkErrorResponse(response)) {
throw new Error('Network error');
}
console.warn('Failed to fetch cache range');
return { data: null, error: null };
}
const data = await response.json();
state.cacheRangeInfo = data;
// Update UI
const rangeTextEl = document.getElementById('cache-range-text');
if (rangeTextEl) {
if (data.totalCacheFolders > 0) {
const spanHours = data.timeSpanMinutes ? Math.round(data.timeSpanMinutes / 60 * 10) / 10 : 0;
rangeTextEl.innerHTML = `
<div>Oldest: ${formatDate(data.oldestCache.cacheTimestamp)}</div>
<div>Newest: ${formatDate(data.newestCache.cacheTimestamp)}</div>
<div>Total: ${data.totalCacheFolders} cache folders (${spanHours} hours)</div>
`;
} else {
rangeTextEl.textContent = 'No cache data available';
}
}
return { data: data, error: null };
} catch (error) {
console.error('Error fetching cache range:', error);
const rangeTextEl = document.getElementById('cache-range-text');
if (rangeTextEl) {
rangeTextEl.textContent = 'Unable to load cache range';
}
if (isNetworkError(error)) {
return { data: null, error: 'network' };
}
return { data: null, error: null };
}
}
/**
* Fetch historical radar data for extended timespan
*/
export async function fetchHistoricalRadar(startTime, endTime) {
try {
let url = `${API_BASE}/timeseries`;
const params = new URLSearchParams();
if (startTime) params.append('startTime', startTime.toISOString());
if (endTime) params.append('endTime', endTime.toISOString());
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url, {
signal: AbortSignal.timeout(30000) // Longer timeout for potentially large responses
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
// Handle 400 Bad Request
if (response.status === 400) {
const errorMessage = errorData.message || 'Invalid request';
let details = '';
if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.requestedRange) {
const requested = errorData.details.requestedRange;
if (requested.requestedHours && errorData.details.maxHours) {
details = ` Requested: ${requested.requestedHours.toFixed(1)} hours, Maximum: ${errorData.details.maxHours} hours.`;
}
}
throw new Error(errorMessage + details);
}
// Handle 404 Not Found
if (response.status === 404) {
if (errorData.errorCode === 'CACHE_NOT_FOUND') {
triggerBackgroundRefresh(errorData.suggestions?.refreshEndpoint);
return {
frames: null,
error: 'location_missing',
cacheStatus: errorData.details || errorData
};
} else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) {
const availableRange = errorData.details.availableRange;
let rangeMessage = '';
if (errorData.suggestions?.suggestedRange) {
const suggested = errorData.suggestions.suggestedRange;
rangeMessage = ` Suggested range: ${formatDate(suggested.start)} to ${formatDate(suggested.end)}.`;
} else if (availableRange.oldest && availableRange.newest) {
rangeMessage = ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.`;
} else if (availableRange.totalCacheFolders > 0) {
rangeMessage = ` ${availableRange.totalCacheFolders} cache folders available.`;
}
if (errorData.suggestions?.suggestion) {
rangeMessage += ` ${errorData.suggestions.suggestion}`;
}
throw new Error(errorData.message + rangeMessage);
} else {
throw new Error(errorData.message || 'No historical data found');
}
}
throw new Error(errorData.message || 'Failed to fetch historical radar');
}
const data = await response.json();
state.historicalData = data;
// Flatten all frames from all cache folders
const allFrames = [];
data.cacheFolders.forEach(cacheFolder => {
cacheFolder.frames.forEach(frame => {
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
frame.observationTime = cacheFolder.observationTime;
frame.cacheFolderName = cacheFolder.cacheFolderName;
allFrames.push(frame);
});
});
// Filter out frames without absoluteObservationTime
const invalidFrames = allFrames.filter(frame => !frame.absoluteObservationTime);
if (invalidFrames.length > 0) {
console.error('Backend returned frames missing absoluteObservationTime:', {
count: invalidFrames.length,
frames: invalidFrames.map((f, idx) => ({
index: idx,
frameIndex: f.frameIndex,
cacheFolder: f.cacheFolderName,
imageUrl: f.imageUrl
}))
});
}
const validFrames = allFrames.filter(frame => frame.absoluteObservationTime);
// Validate chronological order
let previousTime = null;
const outOfOrderFrames = [];
validFrames.forEach((frame, idx) => {
const currentTime = new Date(frame.absoluteObservationTime).getTime();
if (previousTime !== null && currentTime < previousTime) {
outOfOrderFrames.push({
index: idx,
frameIndex: frame.frameIndex,
cacheFolder: frame.cacheFolderName,
absoluteTime: frame.absoluteObservationTime,
previousTime: new Date(previousTime).toISOString(),
timeDiffMinutes: (currentTime - previousTime) / 1000 / 60
});
}
previousTime = currentTime;
});
if (outOfOrderFrames.length > 0) {
console.error('Backend returned frames out of chronological order:', {
count: outOfOrderFrames.length,
issues: outOfOrderFrames
});
}
// Re-index frames sequentially for display
validFrames.forEach((frame, idx) => {
frame.sequentialIndex = idx;
});
return { frames: validFrames, error: null };
} catch (error) {
console.error('Error fetching historical radar:', error);
if (isNetworkError(error)) {
return { frames: null, error: 'network' };
}
return { frames: [], error: null };
}
}
/**
* Fetch metadata separately
*/
export async function fetchMetadata() {
try {
const response = await fetch(`${API_BASE}/metadata`, {
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
return await response.json();
}
} catch (error) {
console.debug('Could not fetch metadata:', error);
}
return null;
}
/**
* Fetch latest radar data (for latest mode)
*/
export async function fetchLatestRadar() {
const response = await fetch(API_BASE, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` }));
if (response.status === 404) {
const errorData = await response.json().catch(() => ({}));
state.lastRefreshTime = new Date();
const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint;
triggerBackgroundRefresh(refreshEndpoint);
const cacheStatusData = createCacheStatusData(errorData);
const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30;
const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`;
return {
error: 'cache_generating',
cacheStatus: cacheStatusData,
message: message,
refreshEndpoint: refreshEndpoint
};
}
throw new Error(error.message || error.error || 'Failed to fetch radar data');
}
const data = await response.json();
data.isExtendedMode = false;
return { data: data, error: null };
}
/**
* Main function to fetch radar data (handles both latest and extended modes)
*/
export async function fetchRadarData() {
try {
// Extended mode
if (state.settings.timespan !== 'latest') {
// Fetch cache range first if needed
if (!state.cacheRangeInfo) {
const rangeResult = await fetchCacheRange();
if (rangeResult.error === 'network') {
throw new Error('Network error - API unavailable');
}
state.cacheRangeInfo = rangeResult.data;
}
if (!state.cacheRangeInfo || state.cacheRangeInfo.totalCacheFolders === 0) {
const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`;
triggerBackgroundRefresh(refreshEndpoint);
// Try to fetch cache status
try {
const statusResponse = await fetch(`${API_BASE}`, {
signal: AbortSignal.timeout(5000)
});
if (statusResponse.status === 404) {
const errorData = await statusResponse.json().catch(() => ({}));
return {
error: 'no_cache_data',
cacheStatus: createCacheStatusData(errorData),
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
};
}
} catch (err) {
console.debug('Could not fetch cache status:', err);
}
return {
error: 'no_cache_data',
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
};
}
// Calculate time range
let startTime = null;
let endTime = new Date();
if (state.settings.timespan === 'custom') {
const startInput = document.getElementById('start-time-input');
const endInput = document.getElementById('end-time-input');
if (startInput && startInput.value) startTime = new Date(startInput.value);
if (endInput && endInput.value) endTime = new Date(endInput.value);
} else {
const hours = parseInt(state.settings.timespan.replace('h', '')) || 1;
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
}
// Fetch historical data
const result = await fetchHistoricalRadar(startTime, endTime);
if (result.error === 'network') {
throw new Error('Network error - API unavailable');
}
if (result.error === 'location_missing') {
return {
error: 'location_missing',
cacheStatus: createCacheStatusData(result.cacheStatus || {}),
message: 'No cached data found for this location. Cache update has been triggered in background.'
};
}
if (!result.frames || result.frames.length === 0) {
return {
error: 'no_frames',
message: 'No frames found for selected timespan. Try a different range or wait for more cache data.'
};
}
// Fetch metadata
let metadata = null;
try {
const metadataResponse = await fetch(API_BASE, {
signal: AbortSignal.timeout(5000)
});
if (metadataResponse.ok) {
const latestData = await metadataResponse.json();
metadata = {
weatherStation: latestData.weatherStation,
distance: latestData.distance,
observationTime: latestData.observationTime,
cacheExpiresAt: latestData.cacheExpiresAt,
nextUpdateTime: latestData.nextUpdateTime,
cacheIsValid: latestData.cacheIsValid,
isUpdating: latestData.isUpdating
};
}
} catch (err) {
console.debug('Could not fetch metadata for extended mode:', err);
}
// Create response object
const newestCacheFolder = state.historicalData.cacheFolders[state.historicalData.cacheFolders.length - 1];
return {
data: {
frames: result.frames,
lastUpdated: endTime.toISOString(),
observationTime: metadata?.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
forecastTime: endTime.toISOString(),
weatherStation: metadata?.weatherStation || null,
distance: metadata?.distance || null,
cacheIsValid: metadata?.cacheIsValid ?? true,
cacheExpiresAt: metadata?.cacheExpiresAt || endTime.toISOString(),
isUpdating: metadata?.isUpdating || false,
nextUpdateTime: metadata?.nextUpdateTime || endTime.toISOString(),
totalFrames: result.frames.length,
isExtendedMode: true
},
error: null
};
} else {
// Latest mode
const result = await fetchLatestRadar();
if (result.error) {
return result;
}
return result;
}
} catch (error) {
console.error('Error fetching radar data:', error);
if (isNetworkError(error)) {
return { error: 'network', originalError: error };
}
return { error: 'unknown', message: error.message };
}
}
+203
View File
@@ -0,0 +1,203 @@
// Main application entry point
import { loadSettings, saveSettings, updateSettingsUI, applySettings } from './settings.js';
import { fetchRadarData } from './api-client.js';
import { updateUI, updateRelativeTimes } from './ui-updater.js';
import { showError, showNoFramesMessage, clearError } from './ui-updater.js';
import { handleApiDown, resetApiRetryState } from './error-handler.js';
import { buildFrameControls, showFrame, findFrameToShowAfterRefresh } from './frame-navigation.js';
import { play, pause, togglePlay, previousFrame, nextFrame } from './slideshow.js';
import { setupKeyboardNavigation } from './keyboard.js';
import { state } from './state.js';
import { isNetworkError } from './utils.js';
/**
* Settings modal management
*/
function showSettings() {
const modal = document.getElementById('settings-modal');
if (modal) modal.style.display = 'flex';
}
function hideSettings() {
const modal = document.getElementById('settings-modal');
if (modal) modal.style.display = 'none';
}
/**
* Auto-refresh data
*/
async function refreshData() {
// Don't clear error if API is down (keep retry message visible)
if (!state.isApiDown) {
clearError();
}
const result = await fetchRadarData();
if (result.error) {
if (result.error === 'network') {
handleApiDown(result.originalError || new Error('Network error'));
return;
}
// Handle other errors
if (result.error === 'cache_generating') {
updateUI(result.cacheStatus);
showNoFramesMessage(result.message, result.refreshEndpoint);
} else if (result.error === 'location_missing' || result.error === 'no_cache_data' || result.error === 'no_frames') {
if (result.cacheStatus) {
updateUI(result.cacheStatus);
}
showNoFramesMessage(result.message, result.refreshEndpoint);
} else {
showError(result.message || 'An error occurred');
}
return;
}
if (result.data) {
state.lastRefreshTime = new Date();
resetApiRetryState();
updateUI(result.data);
// Build frame controls after UI update
buildFrameControls();
// Find and show appropriate frame
const frameToShow = findFrameToShowAfterRefresh();
showFrame(frameToShow);
// Restart auto-refresh if it was stopped
if (!state.refreshInterval && !state.isApiDown) {
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
}
// Restart play if it was playing before refresh
const wasPlaying = state.isPlaying;
if (wasPlaying && result.data.frames && result.data.frames.length > 0) {
setTimeout(() => {
if (!state.isPlaying) {
play();
}
}, 100);
} else if (state.settings.autoPlay && !state.isPlaying && result.data.frames && result.data.frames.length > 0) {
// Auto-play on initial load only
setTimeout(() => play(), 500);
}
}
}
/**
* Initialize application
*/
async function init() {
// Load settings from cookies
loadSettings();
// Set up event listeners
const playBtn = document.getElementById('play-btn');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
if (playBtn) playBtn.addEventListener('click', togglePlay);
if (prevBtn) prevBtn.addEventListener('click', previousFrame);
if (nextBtn) nextBtn.addEventListener('click', nextFrame);
// Settings button
const settingsBtn = document.getElementById('settings-btn-header');
if (settingsBtn) {
settingsBtn.addEventListener('click', showSettings);
}
// Settings modal handlers
const saveSettingsBtn = document.getElementById('save-settings-btn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', () => {
const frameIntervalValue = parseFloat(document.getElementById('frame-interval-input').value);
state.settings.frameInterval = (frameIntervalValue > 0) ? frameIntervalValue : 2.0;
const refreshIntervalValue = parseInt(document.getElementById('refresh-interval-input').value);
state.settings.refreshInterval = (refreshIntervalValue >= 5) ? refreshIntervalValue : 30;
state.settings.autoPlay = document.getElementById('auto-play-input').checked;
state.settings.timespan = document.getElementById('timespan-select').value;
if (state.settings.timespan === 'custom') {
const startInput = document.getElementById('start-time-input');
const endInput = document.getElementById('end-time-input');
if (startInput && startInput.value) {
state.settings.customStartTime = startInput.value;
}
if (endInput && endInput.value) {
state.settings.customEndTime = endInput.value;
}
}
saveSettings();
hideSettings();
// Apply settings and restart refresh interval
applySettings();
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
// Reload data with new timespan
refreshData();
});
}
// Timespan select change handler
const timespanSelect = document.getElementById('timespan-select');
if (timespanSelect) {
timespanSelect.addEventListener('change', (e) => {
state.settings.timespan = e.target.value;
const customSection = document.getElementById('custom-range-section');
if (customSection) {
customSection.style.display = e.target.value === 'custom' ? 'block' : 'none';
}
});
}
// Cancel settings button
const cancelSettingsBtn = document.getElementById('cancel-settings-btn');
if (cancelSettingsBtn) {
cancelSettingsBtn.addEventListener('click', () => {
updateSettingsUI(); // Reset to current settings
hideSettings();
});
}
// Close button handler
const closeSettingsBtn = document.getElementById('close-settings-btn');
if (closeSettingsBtn) {
closeSettingsBtn.addEventListener('click', hideSettings);
}
// Close modal on background click
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.addEventListener('click', (e) => {
if (e.target.id === 'settings-modal') {
hideSettings();
}
});
}
// Setup keyboard navigation
setupKeyboardNavigation();
// Initial load
await refreshData();
// Auto-refresh based on settings (only if API is up)
if (!state.isApiDown) {
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
}
// Update relative times every second
setInterval(updateRelativeTimes, 1000);
}
// Start when page loads
init();
+15
View File
@@ -0,0 +1,15 @@
// Configuration constants
export const MAX_RETRY_ATTEMPTS = 10;
export const RETRY_DELAY_MS = 5000; // 5 seconds between retries
// Get API base URL from global variable set by Razor view
export const API_BASE = window.API_BASE || '';
// Default settings
export const DEFAULT_SETTINGS = {
frameInterval: 2.0, // seconds between frames
refreshInterval: 30, // seconds between API refreshes
autoPlay: true, // auto-play on load
timespan: 'latest' // 'latest', '1h', '3h', '6h', '12h', '24h', 'custom'
};
+68
View File
@@ -0,0 +1,68 @@
// Error handling and API retry logic
import { MAX_RETRY_ATTEMPTS, RETRY_DELAY_MS } from './config.js';
import { state } from './state.js';
import { updateApiStatus, showError, clearError } from './ui-updater.js';
import { fetchRadarData } from './api-client.js';
import { updateUI } from './ui-updater.js';
/**
* Reset API retry state when connection is restored
*/
export function resetApiRetryState() {
if (state.isApiDown) {
state.isApiDown = false;
state.retryAttempts = 0;
clearError();
if (state.retryInterval) {
clearInterval(state.retryInterval);
state.retryInterval = null;
}
updateApiStatus('connected');
}
}
/**
* Handle API being down
*/
export function handleApiDown(error) {
if (!state.isApiDown) {
state.isApiDown = true;
state.retryAttempts = 0;
updateApiStatus('disconnected');
}
state.retryAttempts++;
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
// Stop auto-refresh while retrying
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
// Start retry mechanism
if (!state.retryInterval && state.retryAttempts < MAX_RETRY_ATTEMPTS) {
state.retryInterval = setInterval(async () => {
if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
clearInterval(state.retryInterval);
state.retryInterval = null;
showError('API connection failed after multiple attempts. Please check if the service is running.');
updateApiStatus('failed');
return;
}
state.retryAttempts++;
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
// Try to fetch data
const result = await fetchRadarData();
if (result.data) {
updateUI(result.data);
}
}, RETRY_DELAY_MS);
} else if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
showError('API connection failed after multiple attempts. Please check if the service is running.');
updateApiStatus('failed');
}
}
+305
View File
@@ -0,0 +1,305 @@
// Frame navigation and display
import { formatDate, formatFrameTime, getMinutesAgo, preloadImages } from './utils.js';
import { state } from './state.js';
/**
* Calculate jump amount for frame navigation
* Returns 10% of total frames (min 1, max 50) for extended mode, 0 otherwise
*/
export function getJumpAmount() {
if (!state.isExtendedMode || state.frames.length === 0) {
return 0;
}
return Math.max(1, Math.min(50, Math.round(state.frames.length / 10)));
}
/**
* Build frame controls HTML and attach event listeners
*/
export function buildFrameControls() {
const controlsEl = document.getElementById('frame-controls');
if (!controlsEl) return;
// Calculate jump amount - 10% of total frames, minimum 1, maximum 50
// Only used in extended mode (timeseries)
const jumpAmount = getJumpAmount();
// Build jump buttons HTML only for extended mode
const prevJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
? `<button class="frame-nav-btn" id="frame-prev-jump-btn" title="Go back ${jumpAmount} frames" aria-label="Go back ${jumpAmount} frames">-${jumpAmount}</button>`
: '';
const nextJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
? `<button class="frame-nav-btn" id="frame-next-jump-btn" title="Go forward ${jumpAmount} frames" aria-label="Go forward ${jumpAmount} frames">+${jumpAmount}</button>`
: '';
controlsEl.innerHTML = `
<div class="frame-slider-container">
<div class="frame-slider-wrapper">
<button class="frame-nav-btn" id="frame-first-btn" title="First frame" aria-label="First frame">⏮</button>
${prevJumpButtonHtml}
<input type="range" class="frame-slider" id="frame-slider" min="0" max="${state.frames.length - 1}" value="${state.currentFrameIndex}" aria-label="Frame slider">
${nextJumpButtonHtml}
<button class="frame-nav-btn" id="frame-last-btn" title="Last frame" aria-label="Last frame">⏭</button>
</div>
<div class="frame-info-display">
<div class="frame-info-item">
<span class="frame-info-label">Frame:</span>
<input type="number" class="frame-jump-input" id="frame-jump-input" min="0" max="${state.frames.length - 1}" value="0">
<span class="frame-info-value">/ ${state.frames.length - 1}</span>
</div>
<div class="frame-info-item">
<span class="frame-info-label">Time:</span>
<span class="frame-info-value" id="current-frame-time">-</span>
</div>
<div class="frame-info-item">
<span class="frame-info-label">Progress:</span>
<span class="frame-info-value" id="frame-progress">0%</span>
</div>
</div>
</div>
`;
// Attach event listeners
const firstBtn = document.getElementById('frame-first-btn');
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
const lastBtn = document.getElementById('frame-last-btn');
const slider = document.getElementById('frame-slider');
const jumpInput = document.getElementById('frame-jump-input');
if (firstBtn) firstBtn.addEventListener('click', () => showFrame(0));
if (prevJumpBtn && jumpAmount > 0) {
prevJumpBtn.addEventListener('click', () => jumpFrame(-jumpAmount));
}
if (nextJumpBtn && jumpAmount > 0) {
nextJumpBtn.addEventListener('click', () => jumpFrame(jumpAmount));
}
if (lastBtn) lastBtn.addEventListener('click', () => showFrame(state.frames.length - 1));
if (slider) {
slider.addEventListener('input', (e) => updateFrameFromSlider(parseInt(e.target.value)));
}
if (jumpInput) {
jumpInput.addEventListener('change', (e) => jumpToFrame(parseInt(e.target.value)));
jumpInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') jumpToFrame(parseInt(e.target.value));
});
}
// Update initial display
updateFrameSliderInfo(state.currentFrameIndex);
// Preload all frame images
preloadImages(state.frames);
}
/**
* Update frame slider info display
*/
export function updateFrameSliderInfo(index) {
if (index < 0 || index >= state.frames.length) return;
const frame = state.frames[index];
const progress = Math.round(((index + 1) / state.frames.length) * 100);
// Update jump input
const jumpInput = document.getElementById('frame-jump-input');
if (jumpInput) jumpInput.value = index;
// Update slider
const slider = document.getElementById('frame-slider');
if (slider) slider.value = index;
// Update frame number display
const frameNumDisplay = document.getElementById('current-frame-num-display');
if (frameNumDisplay) frameNumDisplay.textContent = index + 1;
// Update time display
const timeDisplay = document.getElementById('current-frame-time');
if (timeDisplay) {
timeDisplay.textContent = formatFrameTime(frame, state.isExtendedMode);
}
// Update progress
const progressDisplay = document.getElementById('frame-progress');
if (progressDisplay) progressDisplay.textContent = `${progress}%`;
// Update navigation buttons
const firstBtn = document.getElementById('frame-first-btn');
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
const lastBtn = document.getElementById('frame-last-btn');
if (firstBtn) firstBtn.disabled = index === 0;
if (prevJumpBtn) prevJumpBtn.disabled = index === 0;
if (nextJumpBtn) nextJumpBtn.disabled = index >= state.frames.length - 1;
if (lastBtn) lastBtn.disabled = index >= state.frames.length - 1;
}
let sliderUpdateTimeout;
/**
* Update frame from slider (with debouncing)
*/
export function updateFrameFromSlider(value) {
clearTimeout(sliderUpdateTimeout);
sliderUpdateTimeout = setTimeout(() => {
showFrame(value);
}, 50);
}
/**
* Jump to specific frame number
*/
export function jumpToFrame(frameNum) {
const index = Math.max(0, Math.min(state.frames.length - 1, frameNum));
showFrame(index);
}
/**
* Jump forward/backward by N frames
*/
export function jumpFrame(offset) {
const newIndex = Math.max(0, Math.min(state.frames.length - 1, state.currentFrameIndex + offset));
showFrame(newIndex);
}
/**
* Build alt text for frame image
*/
function buildFrameAltText(frame, index) {
if (state.isExtendedMode) {
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
const timeInfo = frame.absoluteObservationTime
? formatDate(frame.absoluteObservationTime)
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
return `Radar frame ${frameNum}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
if (minutesAgo !== null) {
return `Radar frame ${frame.frameIndex} (${minutesAgo} minutes ago)`;
} else {
// Fall back to formatted date if minutes ago calculation fails
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
return `Radar frame ${frame.frameIndex}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
}
}
}
/**
* Build frame info text
*/
function buildFrameInfoText(frame, index) {
if (state.isExtendedMode) {
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
const timeInfo = frame.absoluteObservationTime
? formatDate(frame.absoluteObservationTime)
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
return `Frame ${frameNum} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
if (minutesAgo !== null) {
return `Frame ${frame.frameIndex} of ${state.frames.length - 1} • ${minutesAgo} minutes ago`;
} else {
// Fall back to formatted date if minutes ago calculation fails
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
return `Frame ${frame.frameIndex} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
}
}
}
/**
* Show specific frame
*/
export function showFrame(index) {
if (index < 0 || index >= state.frames.length) {
console.error(`showFrame: Invalid index ${index}, frames.length = ${state.frames.length}`);
return;
}
if (!state.frames[index]) {
console.error(`showFrame: Frame at index ${index} is undefined`);
return;
}
state.currentFrameIndex = index;
const frame = state.frames[index];
// Update slider info
updateFrameSliderInfo(index);
// Update active button state
document.querySelectorAll('.frame-btn').forEach(btn => {
btn.classList.remove('active');
if (parseInt(btn.getAttribute('data-frame')) === index) {
btn.classList.add('active');
}
});
// Update image
const imgEl = document.getElementById('radar-image');
const loadingEl = document.getElementById('loading');
const altText = buildFrameAltText(frame, index);
// Check if image is already loaded (from preload)
const preloadedImg = new Image();
preloadedImg.onload = () => {
if (imgEl) {
imgEl.src = frame.imageUrl;
imgEl.alt = altText;
}
if (loadingEl) loadingEl.style.display = 'none';
if (imgEl) imgEl.style.display = 'block';
};
preloadedImg.onerror = () => {
if (imgEl) {
imgEl.src = frame.imageUrl;
imgEl.alt = altText;
}
};
preloadedImg.src = frame.imageUrl;
// Update frame info
const frameInfoEl = document.getElementById('frame-info');
if (frameInfoEl) {
frameInfoEl.textContent = buildFrameInfoText(frame, index);
}
}
/**
* Find frame to show after refresh (preserves position)
*/
export function findFrameToShowAfterRefresh() {
if (state.frames.length === 0) return 0;
const previousFrameIndex = state.currentFrameIndex;
if (previousFrameIndex < 0 || !state.radarData?.frames) return 0;
const previousFrame = state.radarData.frames[previousFrameIndex];
if (!previousFrame) return 0;
if (previousFrame.absoluteObservationTime) {
// Find frame with same absolute observation time
const matchingIndex = state.frames.findIndex(f =>
f.absoluteObservationTime === previousFrame.absoluteObservationTime
);
if (matchingIndex >= 0) return matchingIndex;
// Find closest frame by time
const previousTime = new Date(previousFrame.absoluteObservationTime).getTime();
let closestIndex = 0;
let closestDiff = Infinity;
state.frames.forEach((f, idx) => {
if (f.absoluteObservationTime) {
const diff = Math.abs(new Date(f.absoluteObservationTime).getTime() - previousTime);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = idx;
}
}
});
return closestIndex;
}
// Fallback: keep same index if valid
return previousFrameIndex < state.frames.length ? previousFrameIndex : 0;
}
+59
View File
@@ -0,0 +1,59 @@
// Keyboard navigation
import { state } from './state.js';
import { showFrame, jumpFrame, getJumpAmount } from './frame-navigation.js';
import { togglePlay } from './slideshow.js';
/**
* Setup keyboard navigation
*/
export function setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
// Don't handle keyboard shortcuts if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
// Arrow keys for frame navigation
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
if (e.key === 'ArrowLeft') {
if (e.shiftKey) {
// Shift+Left: jump back by proportional amount (only in extended mode)
const jumpAmount = getJumpAmount();
if (jumpAmount > 0) {
jumpFrame(-jumpAmount);
}
} else {
showFrame(Math.max(0, state.currentFrameIndex - 1)); // Left: previous frame
}
} else {
if (e.shiftKey) {
// Shift+Right: jump forward by proportional amount (only in extended mode)
const jumpAmount = getJumpAmount();
if (jumpAmount > 0) {
jumpFrame(jumpAmount);
}
} else {
showFrame(Math.min(state.frames.length - 1, state.currentFrameIndex + 1)); // Right: next frame
}
}
}
// Home/End for first/last frame
if (e.key === 'Home') {
e.preventDefault();
showFrame(0);
}
if (e.key === 'End') {
e.preventDefault();
showFrame(state.frames.length - 1);
}
// Spacebar for play/pause
if (e.key === ' ' && state.frames.length > 0) {
e.preventDefault();
togglePlay();
}
});
}
+62
View File
@@ -0,0 +1,62 @@
// Settings management
import { getCookie, setCookie } from './utils.js';
import { state } from './state.js';
import { fetchCacheRange } from './api-client.js';
import { pause, play } from './slideshow.js';
/**
* Load settings from cookies
*/
export function loadSettings() {
const saved = getCookie('radarTestSettings');
if (saved) {
state.settings = { ...state.settings, ...saved };
}
updateSettingsUI();
}
/**
* Save settings to cookies
*/
export function saveSettings() {
setCookie('radarTestSettings', state.settings);
updateSettingsUI();
applySettings();
}
/**
* Update settings UI elements
*/
export function updateSettingsUI() {
document.getElementById('frame-interval-input').value = state.settings.frameInterval;
document.getElementById('refresh-interval-input').value = state.settings.refreshInterval;
document.getElementById('auto-play-input').checked = state.settings.autoPlay;
document.getElementById('timespan-select').value = state.settings.timespan || 'latest';
// Show/hide custom range section
const customSection = document.getElementById('custom-range-section');
if (customSection) {
customSection.style.display = state.settings.timespan === 'custom' ? 'block' : 'none';
}
// Load cache range info when settings modal opens
fetchCacheRange();
}
/**
* Apply settings (restart intervals if needed)
*/
export function applySettings() {
// Restart play interval if playing
if (state.isPlaying) {
pause();
play();
}
// Restart refresh interval (will be handled by refreshData caller)
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
}
+67
View File
@@ -0,0 +1,67 @@
// Slideshow controls (play/pause)
import { state } from './state.js';
import { showFrame } from './frame-navigation.js';
/**
* Play slideshow
*/
export function play() {
if (state.frames.length === 0) return;
state.isPlaying = true;
const playBtn = document.getElementById('play-btn');
if (playBtn) playBtn.textContent = '⏸ Pause';
if (state.playInterval) {
clearInterval(state.playInterval);
}
state.playInterval = setInterval(() => {
state.currentFrameIndex = (state.currentFrameIndex + 1) % state.frames.length;
showFrame(state.currentFrameIndex);
}, state.settings.frameInterval * 1000);
}
/**
* Pause slideshow
*/
export function pause() {
state.isPlaying = false;
const playBtn = document.getElementById('play-btn');
if (playBtn) playBtn.textContent = '▶ Play';
if (state.playInterval) {
clearInterval(state.playInterval);
state.playInterval = null;
}
}
/**
* Toggle play/pause
*/
export function togglePlay() {
if (state.isPlaying) {
pause();
} else {
play();
}
}
/**
* Go to previous frame
*/
export function previousFrame() {
pause();
const newIndex = state.currentFrameIndex > 0 ? state.currentFrameIndex - 1 : state.frames.length - 1;
showFrame(newIndex);
}
/**
* Go to next frame
*/
export function nextFrame() {
pause();
const newIndex = (state.currentFrameIndex + 1) % state.frames.length;
showFrame(newIndex);
}
+41
View File
@@ -0,0 +1,41 @@
// Application state management
import { DEFAULT_SETTINGS } from './config.js';
// Export a mutable state object - properties can be mutated even though the object binding is read-only
export const state = {
currentFrameIndex: 0,
frames: [],
radarData: null,
playInterval: null,
isPlaying: false,
lastRefreshTime: null,
refreshInterval: null,
nextClientCheckTime: null,
retryInterval: null,
isApiDown: false,
retryAttempts: 0,
cacheRangeInfo: null,
historicalData: null,
isExtendedMode: false,
settings: { ...DEFAULT_SETTINGS }
};
// Reset state (for testing/debugging)
export function resetState() {
state.currentFrameIndex = 0;
state.frames = [];
state.radarData = null;
state.playInterval = null;
state.isPlaying = false;
state.lastRefreshTime = null;
state.refreshInterval = null;
state.nextClientCheckTime = null;
state.retryInterval = null;
state.isApiDown = false;
state.retryAttempts = 0;
state.cacheRangeInfo = null;
state.historicalData = null;
state.isExtendedMode = false;
state.settings = { ...DEFAULT_SETTINGS };
}
+281
View File
@@ -0,0 +1,281 @@
// UI update functions
import { formatDate, getRelativeTime, setElementText, setElementHTML, getElement } from './utils.js';
import { state } from './state.js';
/**
* Update API connection status indicator
*/
export function updateApiStatus(status) {
const refreshStatusEl = getElement('refresh-status');
const refreshIndicatorEl = getElement('refresh-indicator');
if (!refreshStatusEl || !refreshIndicatorEl) return;
if (status === 'connected') {
refreshStatusEl.textContent = 'Active';
refreshIndicatorEl.style.background = '#28a745';
refreshIndicatorEl.style.animation = 'pulse 2s infinite';
} else if (status === 'disconnected') {
refreshStatusEl.textContent = 'Reconnecting...';
refreshIndicatorEl.style.background = '#ffc107';
refreshIndicatorEl.style.animation = 'pulse 1s infinite';
} else if (status === 'failed') {
refreshStatusEl.textContent = 'Connection Failed';
refreshIndicatorEl.style.background = '#dc3545';
refreshIndicatorEl.style.animation = 'none';
}
}
/**
* Show error message
*/
export function showError(message) {
const container = getElement('error-container');
if (!container) return;
const isRetrying = message.includes('Retrying');
const errorClass = isRetrying ? 'error retrying' : 'error';
container.innerHTML = `<div class="${errorClass}">${isRetrying ? '⚠️ ' : '❌ Error: '}${message}</div>`;
}
/**
* Show graceful "no frames" message
*/
export function showNoFramesMessage(message, refreshEndpoint = null) {
const container = getElement('error-container');
if (!container) return;
let html = `<div class="error retrying" style="background: #fff3cd; color: #856404; border-left-color: #ffc107;">
<div style="margin-bottom: 10px;">⏳ ${message}</div>`;
if (refreshEndpoint) {
html += `<button class="play-btn" onclick="window.location.reload()" style="margin-top: 10px; padding: 8px 16px; font-size: 0.9em;">
🔄 Refresh Now
</button>`;
}
html += `</div>`;
container.innerHTML = html;
// Hide loading and show message in image container
const loadingEl = getElement('loading');
const imageEl = getElement('radar-image');
if (loadingEl) loadingEl.textContent = message;
if (imageEl) imageEl.style.display = 'none';
}
/**
* Clear error message
*/
export function clearError() {
const container = getElement('error-container');
if (container) container.innerHTML = '';
}
/**
* Update relative times periodically
*/
export function updateRelativeTimes() {
if (!state.radarData) return;
const expiresEl = getElement('cache-expires-relative');
const nextUpdateEl = getElement('next-update-relative');
const updateStatusDetailEl = getElement('update-status-detail');
const nextCheckEl = getElement('next-client-check');
const estimationNoteEl = getElement('estimation-note');
if (state.radarData.cacheExpiresAt && expiresEl) {
expiresEl.textContent = getRelativeTime(state.radarData.cacheExpiresAt);
}
if (state.radarData.nextUpdateTime && nextUpdateEl) {
nextUpdateEl.textContent = getRelativeTime(state.radarData.nextUpdateTime);
}
// Update update status detail with relative time
if (updateStatusDetailEl) {
if (state.radarData.isUpdating && state.radarData.nextUpdateTime) {
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else if (!state.radarData.cacheIsValid && !state.radarData.isUpdating && state.radarData.nextUpdateTime) {
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else if (state.radarData.cacheIsValid) {
updateStatusDetailEl.textContent = 'Cache is valid, no update needed';
updateStatusDetailEl.title = '';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
} else {
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
}
// Update next client check time
if (state.nextClientCheckTime && nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
/**
* Update status cards with cache update information
*/
export function updateStatusForCacheUpdate(retryAfter) {
const statusEl = getElement('cache-status');
const updateStatusDetailEl = getElement('update-status-detail');
if (statusEl) {
statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
}
if (updateStatusDetailEl) {
if (retryAfter) {
const estimatedTime = new Date(Date.now() + (retryAfter * 1000));
updateStatusDetailEl.textContent = `Cache generation in progress. Estimated ready in ${retryAfter} seconds.`;
setElementText('next-update', formatDate(estimatedTime.toISOString()));
} else {
updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
setElementText('next-update', '-');
}
}
// Set other fields to indicate cache is being generated
setElementText('observation-time', '-');
setElementText('cache-expires', '-');
setElementText('weather-station', '-');
setElementText('distance', '-');
// Update last refresh
if (state.lastRefreshTime) {
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
// Calculate and display next client check time
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
const nextCheckEl = getElement('next-client-check');
if (nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
}
/**
* Update UI with radar data (main update function)
*/
export function updateUI(data) {
// Update cache status even if no frames are available
if (data) {
const statusEl = getElement('cache-status');
if (statusEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
let statusHtml = '';
if (data.isUpdating) {
statusHtml = 'Updating <span class="status-badge status-updating">IN PROGRESS</span>';
} else if (data.cacheIsValid) {
statusHtml = 'Valid <span class="status-badge status-valid">ACTIVE</span>';
} else {
statusHtml = 'Invalid <span class="status-badge status-invalid">EXPIRED</span>';
}
statusEl.innerHTML = statusHtml;
}
// Update update status detail
const updateStatusDetailEl = getElement('update-status-detail');
const estimationNoteEl = getElement('estimation-note');
if (updateStatusDetailEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
if (data.isUpdating) {
if (data.nextUpdateTime) {
const relativeTime = getRelativeTime(data.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else {
updateStatusDetailEl.textContent = 'Update in progress, please wait...';
updateStatusDetailEl.title = 'Cache update is in progress. Completion time will be estimated once progress tracking begins.';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
} else if (data.cacheIsValid) {
updateStatusDetailEl.textContent = 'Cache is valid, no update needed';
updateStatusDetailEl.title = '';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
} else {
if (data.nextUpdateTime) {
const relativeTime = getRelativeTime(data.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else {
updateStatusDetailEl.textContent = 'Update will be triggered by background service';
updateStatusDetailEl.title = 'The background cache management service will check and update the cache periodically.';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
}
}
// Update other metadata fields
if (data.observationTime) setElementText('observation-time', formatDate(data.observationTime));
if (data.cacheExpiresAt) setElementText('cache-expires', formatDate(data.cacheExpiresAt));
if (data.nextUpdateTime) setElementText('next-update', formatDate(data.nextUpdateTime));
if (data.weatherStation !== undefined) setElementText('weather-station', data.weatherStation || '-');
if (data.distance !== undefined) setElementText('distance', data.distance || '-');
}
// Handle no frames case
if (!data || !data.frames || data.frames.length === 0) {
const currentStatus = getElement('cache-status')?.innerHTML;
if (!currentStatus || currentStatus === 'Checking...' || currentStatus.trim() === '') {
const statusEl = getElement('cache-status');
const updateStatusDetailEl = getElement('update-status-detail');
if (statusEl) statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
if (updateStatusDetailEl) updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
}
showNoFramesMessage('No radar frames available yet. Cache is being generated in the background. Please wait a moment and refresh.');
state.frames = [];
const controlsEl = getElement('frame-controls');
if (controlsEl) controlsEl.innerHTML = '';
setElementText('play-btn', '▶ Play');
const playBtn = getElement('play-btn');
const prevBtn = getElement('prev-btn');
const nextBtn = getElement('next-btn');
if (playBtn) playBtn.disabled = true;
if (prevBtn) prevBtn.disabled = true;
if (nextBtn) nextBtn.disabled = true;
setElementText('frame-info', 'Waiting for cache to be generated...');
return;
}
// Update state
state.radarData = data;
state.isExtendedMode = data.isExtendedMode || false;
// Enable controls
const playBtn = getElement('play-btn');
const prevBtn = getElement('prev-btn');
const nextBtn = getElement('next-btn');
if (playBtn) playBtn.disabled = false;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
// Sort frames appropriately
if (state.isExtendedMode) {
state.frames = data.frames;
} else {
state.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex);
}
// Update metadata fields (redundant updates removed since already done above)
// Update last refresh
if (state.lastRefreshTime) {
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
const nextCheckEl = getElement('next-client-check');
if (nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
// Frame controls will be built by the caller (to avoid circular dependencies)
}
+184
View File
@@ -0,0 +1,184 @@
// Utility functions
// Cookie management
export function setCookie(name, value, days = 365) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))};expires=${expires.toUTCString()};path=/`;
}
export function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) {
try {
return JSON.parse(decodeURIComponent(c.substring(nameEQ.length, c.length)));
} catch (e) {
return null;
}
}
}
return null;
}
// Date formatting utilities
export function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('en-AU', {
timeZone: 'Australia/Brisbane',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
export function getRelativeTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = date - now;
const diffMins = Math.floor(diffMs / 60000);
const diffSecs = Math.floor((diffMs % 60000) / 1000);
if (diffMs < 0) {
return Math.abs(diffMins) + ' minute(s) ago';
} else if (diffMins > 0) {
return 'in ' + diffMins + ' minute(s)';
} else {
return 'in ' + diffSecs + ' second(s)';
}
}
export function getMinutesAgo(dateString) {
if (!dateString) return null;
// Handle both string and Date object inputs
let date;
if (typeof dateString === 'string') {
date = new Date(dateString);
} else if (dateString instanceof Date) {
date = dateString;
} else {
console.warn('getMinutesAgo: Expected string or Date, got:', typeof dateString, dateString);
return null;
}
// Check if date is valid
if (isNaN(date.getTime())) {
console.warn('getMinutesAgo: Invalid date:', dateString, 'parsed as:', date);
return null;
}
// Calculate difference in milliseconds
// Date.now() is UTC-based, date.getTime() is UTC-based, so this should be correct
const now = Date.now();
const dateTime = date.getTime();
const diffMs = now - dateTime;
// Convert to minutes and round
const minutes = Math.round(diffMs / 60000);
// If negative (future date), return null
if (minutes < 0) {
console.warn('getMinutesAgo: Future date detected:', dateString, 'parsed as:', date.toISOString());
return null;
}
// Safeguard against unreasonably large values (likely parsing errors)
// But don't cap at 60 - radar frames can legitimately be older in some cases
if (minutes > 100000) { // e.g., more than ~69 days ago
console.warn('getMinutesAgo: Unreasonably large minutes value:', minutes, 'for date:', dateString, 'parsed as:', date.toISOString());
return null;
}
return minutes;
}
// Format frame time for display (consolidates duplicate logic)
export function formatFrameTime(frame, isExtendedMode) {
if (!frame.absoluteObservationTime) return '-';
if (isExtendedMode) {
return formatDate(frame.absoluteObservationTime);
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
// If minutesAgo is null (invalid date or edge case), fall back to formatted date
if (minutesAgo === null) {
return formatDate(frame.absoluteObservationTime);
}
return `${minutesAgo} min ago`;
}
}
// Network error detection (consolidates duplicate checks)
export function isNetworkError(error) {
return error.name === 'TypeError' ||
error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('Failed to fetch') ||
error.message === 'Network error';
}
export function isNetworkErrorResponse(response) {
return response.status === 0 || response.status >= 500;
}
// DOM helpers
export function getElement(id) {
return document.getElementById(id);
}
export function setElementText(id, text) {
const el = getElement(id);
if (el) el.textContent = text;
}
export function setElementHTML(id, html) {
const el = getElement(id);
if (el) el.innerHTML = html;
}
// Create cache status data object (consolidates duplicate creation)
export function createCacheStatusData(errorData) {
const details = errorData.details || errorData;
const isUpdating = details.updateTriggered === true ||
(errorData.message && errorData.message.includes('in progress')) ||
(details.statusMessage && details.statusMessage.includes('in progress'));
return {
frames: [],
isUpdating: isUpdating,
cacheIsValid: details.cacheIsValid || false,
cacheExpiresAt: details.cacheExpiresAt || null,
nextUpdateTime: details.nextUpdateTime || null
};
}
// Trigger background cache refresh (consolidates duplicate calls)
export function triggerBackgroundRefresh(refreshEndpoint) {
if (refreshEndpoint) {
fetch(refreshEndpoint, {
method: 'POST',
signal: AbortSignal.timeout(5000)
}).catch(err => {
console.debug('Background cache refresh trigger failed (non-critical):', err);
});
}
}
// Preload all frame images to prevent jiggle
export function preloadImages(frames) {
frames.forEach(frame => {
const img = new Image();
img.src = frame.imageUrl;
});
}