mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-04 03:28:11 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a257931e8 | ||
|
|
0a29c2d0c1 | ||
|
|
ffd3636d1c | ||
|
|
b30d76ddec | ||
|
|
12dd0eedad | ||
|
|
5e31ed33c6 | ||
|
|
92c241b26e | ||
|
|
0ace8f4bc5 | ||
|
|
ba8694947e | ||
|
|
d60706f625 | ||
|
|
ee5bffde88 | ||
|
|
e5aee5be82 | ||
|
|
319d397b41 |
@@ -21,6 +21,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -40,6 +46,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -35,14 +35,21 @@ public class CacheController : ControllerBase
|
||||
var validationError = ValidationHelper.ValidateLocation(suburb, state);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
|
||||
}
|
||||
|
||||
var result = await _bomRadarService.GetCacheRangeAsync(suburb, state, cancellationToken);
|
||||
|
||||
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);
|
||||
@@ -50,7 +57,11 @@ public class CacheController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
|
||||
}
|
||||
|
||||
var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken);
|
||||
@@ -74,7 +85,12 @@ public class CacheController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
|
||||
}
|
||||
|
||||
var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken);
|
||||
@@ -100,13 +116,22 @@ public class CacheController : ControllerBase
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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
@@ -37,7 +37,7 @@ public class RadarController : ControllerBase
|
||||
var validationError = ValidationHelper.ValidateLocation(suburb, state);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
|
||||
}
|
||||
|
||||
// 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
|
||||
// Use cache service directly since this is cache management, not radar-specific
|
||||
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
var 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.");
|
||||
}
|
||||
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,
|
||||
@@ -70,17 +81,37 @@ public class RadarController : ControllerBase
|
||||
cacheManagementCheckIntervalMinutes,
|
||||
cancellationToken);
|
||||
|
||||
return NotFound(new {
|
||||
error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.",
|
||||
retryAfter = 30, // seconds
|
||||
refreshEndpoint = $"/api/cache/{suburb}/{state}/refresh",
|
||||
updateTriggered = cacheStatus.UpdateTriggered,
|
||||
cacheExists = cacheStatus.CacheExists,
|
||||
cacheIsValid = cacheStatus.CacheIsValid,
|
||||
cacheExpiresAt = cacheStatus.CacheExpiresAt,
|
||||
nextUpdateTime = cacheStatus.NextUpdateTime,
|
||||
message = cacheStatus.Message
|
||||
});
|
||||
// Determine if update was triggered (check if it's already in progress or was just triggered)
|
||||
var updateTriggered = cacheStatus.UpdateTriggered ||
|
||||
(cacheStatus.Message?.Contains("in progress") ?? false) ||
|
||||
(!cacheStatus.CacheExists && !cacheStatus.CacheIsValid);
|
||||
|
||||
var errorResponse = ApiErrorResponseBuilder.CacheNotFound(suburb, state, cacheStatus, updateTriggered);
|
||||
|
||||
// Enhance message based on cache status
|
||||
if (cacheStatus.UpdateFailed)
|
||||
{
|
||||
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
|
||||
@@ -97,7 +128,11 @@ public class RadarController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
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;
|
||||
@@ -130,7 +173,17 @@ public class RadarController : ControllerBase
|
||||
|
||||
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);
|
||||
@@ -139,7 +192,12 @@ public class RadarController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
|
||||
}
|
||||
|
||||
var result = await _bomRadarService.GetLastUpdatedInfoAsync(suburb, state, cancellationToken);
|
||||
|
||||
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);
|
||||
@@ -169,7 +237,11 @@ public class RadarController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
return BadRequest(ApiErrorResponseBuilder.ValidationError(validationError));
|
||||
}
|
||||
|
||||
DateTime? start = null;
|
||||
@@ -200,7 +272,9 @@ public class RadarController : ControllerBase
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -209,21 +283,129 @@ public class RadarController : ControllerBase
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
@@ -231,7 +413,12 @@ public class RadarController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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
@@ -24,7 +24,7 @@ RUN apt-get update && \
|
||||
chmod +x dotnet-install.sh && \
|
||||
./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet && \
|
||||
rm dotnet-install.sh && \
|
||||
apt-get install -y xvfb x11vnc fluxbox && \
|
||||
apt-get install -y xvfb x11vnc fluxbox curl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -34,12 +34,9 @@ COPY --from=publish /app/publish .
|
||||
# Create cache directory for screenshots
|
||||
RUN mkdir -p /app/cache && chmod 777 /app/cache
|
||||
|
||||
# Create startup script to run Xvfb and then the app
|
||||
RUN echo '#!/bin/bash\n\
|
||||
Xvfb :99 -screen 0 1920x1080x24 > /dev/null 2>&1 &\n\
|
||||
export DISPLAY=:99\n\
|
||||
exec dotnet BomLocalService.dll' > /app/start.sh && \
|
||||
chmod +x /app/start.sh
|
||||
# Copy startup script
|
||||
COPY scripts/docker-start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
# OCI labels for GitHub Container Registry metadata
|
||||
LABEL org.opencontainers.image.source="https://github.com/alexhopeoconnor/bom-local-service"
|
||||
@@ -48,4 +45,7 @@ LABEL org.opencontainers.image.licenses=MIT
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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>
|
||||
/// Status information about a cache update operation for a location.
|
||||
/// 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
|
||||
/// - "No cache exists, update triggered" - No cache found, update initiated
|
||||
/// - "Cache update already in progress" - An update is currently running
|
||||
/// - "Cache update failed" - An update was attempted but failed (check Error property)
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ public class FrameMetadata
|
||||
public int FrameIndex { get; set; }
|
||||
|
||||
/// <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>
|
||||
public int MinutesAgo { get; set; }
|
||||
public DateTime ObservationTime { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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""]'));
|
||||
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]{3}),?\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
@@ -2,22 +2,14 @@ namespace BomLocalService.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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>
|
||||
/// The absolute UTC observation time for this frame.
|
||||
/// Calculated as: ObservationTime - MinutesAgo.
|
||||
/// This is set when frames are joined across cache folders in timeseries responses.
|
||||
/// Parsed directly from the frame display label during capture.
|
||||
/// Client calculates "minutes ago" dynamically from this timestamp.
|
||||
/// </summary>
|
||||
public DateTime? AbsoluteObservationTime { get; set; }
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ public class RadarResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public List<RadarFrame> Frames { get; set; } = new();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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]{3},?\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"
|
||||
};
|
||||
}
|
||||
|
||||
+21
-27
@@ -1,5 +1,6 @@
|
||||
using BomLocalService.Services;
|
||||
using BomLocalService.Extensions;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -7,44 +8,34 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Register core services via interfaces (order matters - dependencies must be registered first)
|
||||
builder.Services.AddSingleton<IDebugService, DebugService>();
|
||||
builder.Services.AddSingleton<ICacheService, CacheService>();
|
||||
builder.Services.AddSingleton<ITimeParsingService, TimeParsingService>();
|
||||
builder.Services.AddSingleton<IBrowserService, BrowserService>();
|
||||
builder.Services.AddSingleton<IScrapingService, ScrapingService>();
|
||||
// Configure CORS - MUST be added before other services
|
||||
builder.Services.AddCorsConfiguration(builder.Configuration);
|
||||
|
||||
// Register BOM Radar Service as singleton (orchestrator, depends on all above services)
|
||||
builder.Services.AddSingleton<IBomRadarService, BomLocalService.Services.BomRadarService>();
|
||||
|
||||
// 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);
|
||||
// Register all services via assembly scanning
|
||||
builder.Services.ScanAndRegisterServices(typeof(Program).Assembly);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// HTTPS redirection is optional and disabled by default for Docker flexibility
|
||||
// Users can enable it by setting ENABLE_HTTPS_REDIRECTION=true environment variable
|
||||
// or EnableHttpsRedirection=true in appsettings.json
|
||||
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false) ||
|
||||
Environment.GetEnvironmentVariable("ENABLE_HTTPS_REDIRECTION")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
|
||||
// Users can enable it by setting EnableHttpsRedirection=true in appsettings.json or ENABLEHTTPSREDIRECTION=true environment variable
|
||||
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false);
|
||||
if (enableHttpsRedirection)
|
||||
{
|
||||
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
|
||||
|
||||
// Map MVC routes first (before API routes to avoid conflicts)
|
||||
@@ -55,6 +46,9 @@ app.MapControllerRoute(
|
||||
// Map API controllers (with /api prefix)
|
||||
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
|
||||
var cacheService = app.Services.GetRequiredService<ICacheService>();
|
||||
var deletedCount = cacheService.CleanupIncompleteCacheFolders();
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<p align="center">
|
||||
<img
|
||||
src="https://github.com/user-attachments/assets/684b73cd-a374-49e6-aaf1-ccc4c5697b47"
|
||||
width="500"
|
||||
alt="f67325fe-33ae-4fd8-9e57-38b46899d09c"
|
||||
/>
|
||||
</p>
|
||||
|
||||
# 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.
|
||||
@@ -33,10 +41,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
|
||||
- **CacheService**: Manages file-based storage of radar screenshots and metadata in organized directory structures
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
@@ -46,7 +69,27 @@ Built on ASP.NET Core 9.0, the service uses a service-oriented architecture with
|
||||
- **CacheController**: REST endpoints for cache management operations (`/api/cache/{suburb}/{state}`)
|
||||
- **RadarTestController**: MVC controller serving the demo SPA at `/radar/{suburb}/{state}`
|
||||
|
||||
## Building with Docker
|
||||
## Installation
|
||||
|
||||
### Docker Image
|
||||
|
||||
Pre-built Docker images are available on GitHub Container Registry:
|
||||
|
||||
**Image:** `ghcr.io/alexhopeoconnor/bom-local-service`
|
||||
|
||||
**Multi-architecture support:** Images are built for both `linux/amd64` and `linux/arm64` platforms
|
||||
|
||||
**Pull the latest version:**
|
||||
```bash
|
||||
docker pull ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
```
|
||||
|
||||
**Pull a specific version:**
|
||||
```bash
|
||||
docker pull ghcr.io/alexhopeoconnor/bom-local-service:v0.0.1
|
||||
```
|
||||
|
||||
See all available versions on the [releases page](https://github.com/alexhopeoconnor/bom-local-service/releases).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -55,9 +98,34 @@ Built on ASP.NET Core 9.0, the service uses a service-oriented architecture with
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### Option 1: Using Pre-built Docker Image (Recommended)
|
||||
|
||||
Pull the latest image from GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
```
|
||||
|
||||
Then run the container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name bom-local-service \
|
||||
-p 8082:8080 \
|
||||
-v $(pwd)/cache:/app/cache \
|
||||
--shm-size=1gb \
|
||||
--ipc=host \
|
||||
ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
```
|
||||
|
||||
**Available tags:**
|
||||
- `latest` - Latest release
|
||||
- `v0.0.1` - Specific version (see [releases](https://github.com/alexhopeoconnor/bom-local-service/releases) for all versions)
|
||||
|
||||
#### Option 2: Build from Source
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
git clone https://github.com/alexhopeoconnor/bom-local-service.git
|
||||
cd bom-local-service
|
||||
```
|
||||
|
||||
@@ -86,17 +154,47 @@ Built on ASP.NET Core 9.0, the service uses a service-oriented architecture with
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
The included `docker-compose.yml` provides a convenient way to run the service with all configuration options:
|
||||
The included `docker-compose.yml` provides a convenient way to run the service with all configuration options.
|
||||
|
||||
**Using pre-built image (recommended):**
|
||||
|
||||
Update `docker-compose.yml` to use the GitHub Container Registry image:
|
||||
```yaml
|
||||
services:
|
||||
bom-local-service:
|
||||
image: ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
# Remove or comment out the 'build:' section
|
||||
```
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Building from source:**
|
||||
|
||||
If you want to build locally, keep the `build:` section in `docker-compose.yml` and run:
|
||||
```bash
|
||||
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:
|
||||
- Build the image if it doesn't exist
|
||||
- Pull/build the image as configured
|
||||
- Start the service on port 8082 (configurable via `HOST_PORT`)
|
||||
- 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:
|
||||
```bash
|
||||
@@ -120,14 +218,14 @@ All configuration can be done via environment variables, which override the defa
|
||||
|----------|-------------|---------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` |
|
||||
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
|
||||
| `ENABLE_HTTPS_REDIRECTION` | Enable HTTPS redirection | `false` |
|
||||
| `ENABLEHTTPSREDIRECTION` | Enable HTTPS redirection | `false` |
|
||||
|
||||
#### Application Configuration
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `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` |
|
||||
| `TIMEZONE` | Timezone for time parsing (IANA format) | `Australia/Brisbane` | `Australia/Sydney` |
|
||||
|
||||
@@ -137,8 +235,7 @@ All configuration can be done via environment variables, which override the defa
|
||||
|----------|-------------|---------|---------|
|
||||
| `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` |
|
||||
| `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 | `1` | `2` |
|
||||
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations (used for both initial and periodic updates) | `1` | `2` |
|
||||
|
||||
#### Cache Cleanup
|
||||
|
||||
@@ -150,8 +247,50 @@ All configuration can be done via environment variables, which override the defa
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCREENSHOT__DYNAMICCONTENTWAITMS` | Milliseconds to wait for dynamic content to load | `2000` | `3000` |
|
||||
| `SCREENSHOT__TILERENDERWAITMS` | Milliseconds to wait for map tiles to render | `5000` | `7000` |
|
||||
| `SCREENSHOT__DYNAMICCONTENTWAITMS` | Milliseconds to wait for dynamic content to load | `1500` | `2000` |
|
||||
| `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
|
||||
|
||||
@@ -160,6 +299,13 @@ All configuration can be done via environment variables, which override the defa
|
||||
| `DEBUG__ENABLED` | Enable debug mode (saves debug screenshots) | `false` | `true` |
|
||||
| `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
|
||||
|
||||
| Variable | Description | Default |
|
||||
@@ -180,7 +326,7 @@ docker run -d \
|
||||
-e CACHEEXPIRATIONMINUTES=15 \
|
||||
--shm-size=1gb \
|
||||
--ipc=host \
|
||||
bom-local-service
|
||||
ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
```
|
||||
|
||||
#### Custom Cache Directory
|
||||
@@ -193,7 +339,7 @@ docker run -d \
|
||||
-e CACHEDIRECTORY=/app/cache \
|
||||
--shm-size=1gb \
|
||||
--ipc=host \
|
||||
bom-local-service
|
||||
ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
```
|
||||
|
||||
#### Development Mode with Debug
|
||||
@@ -208,7 +354,7 @@ docker run -d \
|
||||
-e DEBUG__WAITMS=5000 \
|
||||
--shm-size=1gb \
|
||||
--ipc=host \
|
||||
bom-local-service
|
||||
ghcr.io/alexhopeoconnor/bom-local-service:latest
|
||||
```
|
||||
|
||||
#### Using docker-compose with Custom Settings
|
||||
@@ -228,6 +374,126 @@ Then run:
|
||||
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
|
||||
|
||||
The service provides RESTful API endpoints for accessing radar data and managing the cache.
|
||||
@@ -257,8 +523,7 @@ GET /api/radar/{suburb}/{state}
|
||||
{
|
||||
"frameIndex": 0,
|
||||
"imageUrl": "/api/radar/Brisbane/QLD/frame/0",
|
||||
"minutesAgo": 0,
|
||||
"observationTime": "2025-01-15T10:00:00Z"
|
||||
"absoluteObservationTime": "2025-01-15T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"observationTime": "2025-01-15T10:00:00Z",
|
||||
@@ -272,10 +537,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:**
|
||||
- `200 OK`: Radar data available
|
||||
- `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
|
||||
```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
|
||||
|
||||
@@ -294,6 +605,23 @@ GET /api/radar/{suburb}/{state}/frame/{frameIndex}
|
||||
**Response:**
|
||||
- `200 OK`: PNG image
|
||||
- `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
|
||||
|
||||
@@ -328,7 +656,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`)
|
||||
- `endTime` (query, optional): ISO 8601 end time (defaults to now)
|
||||
|
||||
**Response:**
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"cacheFolders": [
|
||||
@@ -340,17 +668,152 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
|
||||
{
|
||||
"frameIndex": 0,
|
||||
"imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000",
|
||||
"absoluteObservationTime": "2025-01-15T10:00:00Z",
|
||||
"minutesAgo": 0
|
||||
"absoluteObservationTime": "2025-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"totalFrames": 7,
|
||||
"timeSpanMinutes": 60
|
||||
"startTime": "2025-01-15T07:00:00Z",
|
||||
"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 information about available historical cache data.
|
||||
@@ -394,6 +857,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 cached data for a location.
|
||||
@@ -409,12 +877,49 @@ DELETE /api/cache/{suburb}/{state}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAPI Documentation
|
||||
## Cache Update Estimation
|
||||
|
||||
When running in Development mode, OpenAPI documentation is available at:
|
||||
```
|
||||
http://localhost:8082/openapi/v1.json
|
||||
```
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -442,11 +947,11 @@ http://localhost:8082/radar/Brisbane/QLD
|
||||
|
||||
- **Slideshow Playback**: Play, pause, and navigate through radar frames
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -468,15 +973,23 @@ async function getRadarData(suburb, state) {
|
||||
if (response.status === 404) {
|
||||
// Cache is being generated - trigger refresh and show message
|
||||
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
|
||||
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) {
|
||||
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();
|
||||
@@ -493,7 +1006,10 @@ if (radarData.frames && radarData.frames.length > 0) {
|
||||
|
||||
// Or loop through all frames for animation
|
||||
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)` : ''}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -509,6 +1025,36 @@ async function getHistoricalRadar(suburb, state, hoursBack = 3) {
|
||||
`/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) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -585,7 +1131,29 @@ curl -X POST http://localhost:8082/api/cache/Brisbane/QLD/refresh
|
||||
|
||||
**Get historical data (last 3 hours):**
|
||||
```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
|
||||
@@ -613,6 +1181,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
|
||||
- **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 browsers (Chromium) can consume significant CPU and memory:
|
||||
@@ -646,6 +1233,25 @@ docker run --cpus="1.0" ...
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
### Building Locally
|
||||
@@ -664,7 +1270,45 @@ Enable debug mode to save intermediate screenshots during data capture:
|
||||
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
|
||||
|
||||
|
||||
+128
-35
@@ -15,6 +15,8 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly double _cacheExpirationMinutes;
|
||||
private readonly int _cacheManagementCheckIntervalMinutes;
|
||||
private readonly int _timeSeriesWarningFolderCount;
|
||||
private readonly int _estimatedUpdateDurationSeconds;
|
||||
|
||||
public BomRadarService(
|
||||
ILogger<BomRadarService> logger,
|
||||
@@ -30,8 +32,48 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_scrapingService = scrapingService;
|
||||
_debugService = debugService;
|
||||
_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)
|
||||
@@ -56,7 +98,11 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
|
||||
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)
|
||||
@@ -104,7 +150,19 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
|
||||
status.UpdateTriggered = false;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -144,14 +202,16 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
? "Cache is stale, update triggered"
|
||||
: "No cache exists, update triggered";
|
||||
|
||||
// Calculate next update time
|
||||
if (status.CacheExpiresAt.HasValue && status.CacheExpiresAt.Value > DateTime.UtcNow)
|
||||
// Try metrics-based estimate first, then fallback to calculated
|
||||
var remainingSeconds = _cacheService.GetEstimatedRemainingSeconds(locationKey);
|
||||
if (remainingSeconds > 0)
|
||||
{
|
||||
status.NextUpdateTime = status.CacheExpiresAt.Value;
|
||||
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
status.NextUpdateTime = DateTime.UtcNow.AddMinutes(_cacheExpirationMinutes);
|
||||
// Fallback to calculated estimate
|
||||
status.NextUpdateTime = DateTime.UtcNow.AddSeconds(_estimatedUpdateDurationSeconds);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -183,7 +243,12 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata);
|
||||
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
|
||||
{
|
||||
@@ -200,7 +265,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_logger.LogInformation("Returning valid cached screenshots for {Suburb}, {State} (no semaphore needed)", suburb, state);
|
||||
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
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
|
||||
{
|
||||
@@ -250,7 +315,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
|
||||
var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
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
|
||||
@@ -275,11 +340,25 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
// Remove from active tracking once complete
|
||||
_cacheService.ClearActiveCacheFolder(locationKey);
|
||||
|
||||
// Update result with cache state
|
||||
result.IsUpdating = false;
|
||||
result.CacheIsValid = true;
|
||||
result.CacheExpiresAt = result.ObservationTime.AddMinutes(_cacheExpirationMinutes);
|
||||
result.NextUpdateTime = result.CacheExpiresAt;
|
||||
// Update result with cache state - rebuild response to get correct NextUpdateTime calculation
|
||||
var cacheExpiresAt = result.ObservationTime.AddMinutes(_cacheExpirationMinutes);
|
||||
var metadata = new LastUpdatedInfo
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -327,7 +406,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 +494,16 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
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 encodedSuburb = Uri.EscapeDataString(suburb);
|
||||
var encodedState = Uri.EscapeDataString(state);
|
||||
@@ -414,7 +512,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
// Use a HashSet to track unique absolute observation times we've already seen
|
||||
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
|
||||
var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync(
|
||||
@@ -430,32 +528,27 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
|
||||
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>();
|
||||
|
||||
foreach (var frame in radarFrames)
|
||||
{
|
||||
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
|
||||
|
||||
// Calculate absolute observation time for this frame
|
||||
// minutesAgo is relative to the cache folder's observation time
|
||||
// Validate that ObservationTime is reasonable (not default/min value)
|
||||
if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0)
|
||||
// Use absolute observation time directly (already set during capture)
|
||||
// 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 &&
|
||||
frame.AbsoluteObservationTime.Value > DateTime.MinValue.AddYears(1) &&
|
||||
!seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
|
||||
{
|
||||
frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo);
|
||||
|
||||
// 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);
|
||||
}
|
||||
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})",
|
||||
frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo);
|
||||
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Missing AbsoluteObservationTime",
|
||||
frame.FrameIndex, folderInfo.FolderName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,15 @@ public class BrowserService : IBrowserService
|
||||
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
|
||||
public class CacheCleanupService : BackgroundService
|
||||
public class CacheCleanupService : BackgroundService, IHostedServiceRegistration
|
||||
{
|
||||
private readonly ILogger<CacheCleanupService> _logger;
|
||||
private readonly string _cacheDirectory;
|
||||
@@ -14,8 +15,20 @@ public class CacheCleanupService : BackgroundService
|
||||
{
|
||||
_logger = logger;
|
||||
_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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
|
||||
public class CacheManagementService : BackgroundService
|
||||
public class CacheManagementService : BackgroundService, IHostedServiceRegistration
|
||||
{
|
||||
private readonly ILogger<CacheManagementService> _logger;
|
||||
private readonly IBomRadarService _bomRadarService;
|
||||
private readonly IBrowserService _browserService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TimeSpan _checkInterval;
|
||||
private readonly TimeSpan _locationStaggerInterval;
|
||||
private readonly TimeSpan _initialDelayInterval;
|
||||
private readonly HashSet<string> _activeUpdates = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
@@ -24,8 +27,34 @@ public class CacheManagementService : BackgroundService
|
||||
_bomRadarService = bomRadarService;
|
||||
_browserService = browserService;
|
||||
_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);
|
||||
|
||||
// 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)
|
||||
@@ -33,8 +62,7 @@ public class CacheManagementService : BackgroundService
|
||||
_logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval);
|
||||
|
||||
// Wait a bit for the service to fully initialize
|
||||
var initialDelaySeconds = _configuration.GetValue<int>("CacheManagement:InitialDelaySeconds", 10);
|
||||
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
|
||||
await Task.Delay(_initialDelayInterval, stoppingToken);
|
||||
|
||||
// Pre-warm the browser before starting cache updates
|
||||
_logger.LogInformation("Pre-warming browser before cache updates");
|
||||
@@ -52,28 +80,7 @@ public class CacheManagementService : BackgroundService
|
||||
|
||||
// Initial cache update on startup
|
||||
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
|
||||
int initialUpdatesTriggered = 0;
|
||||
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);
|
||||
}
|
||||
var (initialUpdatesTriggered, initialCachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
|
||||
|
||||
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
||||
initialUpdatesTriggered, initialCachesValid);
|
||||
@@ -89,28 +96,7 @@ public class CacheManagementService : BackgroundService
|
||||
locationsToManage = GetLocationsToManage();
|
||||
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
|
||||
|
||||
int updatesTriggered = 0;
|
||||
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);
|
||||
}
|
||||
var (updatesTriggered, cachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
|
||||
|
||||
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
|
||||
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)
|
||||
{
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
|
||||
+312
-19
@@ -13,13 +13,32 @@ public class CacheService : ICacheService
|
||||
private readonly double _cacheExpirationMinutes;
|
||||
private readonly IConfiguration _configuration;
|
||||
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)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = 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
|
||||
Directory.CreateDirectory(_cacheDirectory);
|
||||
@@ -90,7 +109,10 @@ public class CacheService : ICacheService
|
||||
string state,
|
||||
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))
|
||||
{
|
||||
@@ -116,7 +138,10 @@ public class CacheService : ICacheService
|
||||
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))
|
||||
{
|
||||
@@ -129,18 +154,18 @@ public class CacheService : ICacheService
|
||||
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 frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
|
||||
var minutesAgo = frameMetadata != null
|
||||
? frameMetadata.MinutesAgo
|
||||
: 40 - (frameIndex * 5);
|
||||
var observationTime = frameMetadata?.ObservationTime > DateTime.MinValue.AddYears(1)
|
||||
? frameMetadata.ObservationTime
|
||||
: (DateTime?)null;
|
||||
|
||||
return new RadarFrame
|
||||
{
|
||||
FrameIndex = frameIndex,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo
|
||||
AbsoluteObservationTime = observationTime
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +200,7 @@ public class CacheService : ICacheService
|
||||
var framesMetadata = frames.Select(f => new FrameMetadata
|
||||
{
|
||||
FrameIndex = f.FrameIndex,
|
||||
MinutesAgo = f.MinutesAgo
|
||||
ObservationTime = f.AbsoluteObservationTime ?? throw new InvalidOperationException($"Frame {f.FrameIndex} missing AbsoluteObservationTime")
|
||||
}).ToList();
|
||||
|
||||
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||
@@ -319,6 +344,10 @@ public class CacheService : ICacheService
|
||||
string state,
|
||||
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 folders = Directory.GetDirectories(_cacheDirectory, pattern)
|
||||
.Select(f =>
|
||||
@@ -339,6 +368,13 @@ public class CacheService : ICacheService
|
||||
if (!Directory.Exists(folder.Folder))
|
||||
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);
|
||||
if (metadata == null)
|
||||
continue;
|
||||
@@ -388,7 +424,8 @@ public class CacheService : ICacheService
|
||||
|
||||
// Load frame metadata from frames.json if available
|
||||
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
|
||||
for (int i = 0; i < frameCount; i++)
|
||||
@@ -398,15 +435,13 @@ public class CacheService : ICacheService
|
||||
{
|
||||
if (dataType == CachedDataType.Radar)
|
||||
{
|
||||
var minutesAgo = metadataDict.ContainsKey(i)
|
||||
? metadataDict[i]
|
||||
: 40 - (i * 5);
|
||||
var observationTime = metadataDict.ContainsKey(i) ? metadataDict[i] : (DateTime?)null;
|
||||
|
||||
frames.Add(new RadarFrame
|
||||
{
|
||||
FrameIndex = i,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo
|
||||
AbsoluteObservationTime = observationTime
|
||||
});
|
||||
}
|
||||
else
|
||||
@@ -497,6 +532,7 @@ public class CacheService : ICacheService
|
||||
public void SetActiveCacheFolder(string locationKey, string cacheFolderPath)
|
||||
{
|
||||
_activeCacheFolders[locationKey] = cacheFolderPath;
|
||||
_updateProgress[locationKey] = (DateTime.UtcNow, CacheUpdatePhase.Initializing, null, null);
|
||||
_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))
|
||||
{
|
||||
RecordUpdateComplete(locationKey);
|
||||
_updateProgress.TryRemove(locationKey, out _);
|
||||
_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)
|
||||
{
|
||||
var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp);
|
||||
@@ -605,8 +890,18 @@ public class CacheService : ICacheService
|
||||
if (isUpdating)
|
||||
{
|
||||
status.Message = "Cache update already in progress";
|
||||
// Update in progress - estimate completion in ~2 minutes
|
||||
status.NextUpdateTime = DateTime.UtcNow.AddMinutes(2);
|
||||
// Try metrics-based estimate first
|
||||
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)
|
||||
{
|
||||
@@ -618,9 +913,7 @@ public class CacheService : ICacheService
|
||||
{
|
||||
// Cache is invalid/missing - next update would be after background service check
|
||||
status.Message = status.CacheExists ? "Cache is stale" : "No cache exists";
|
||||
var now = DateTime.UtcNow;
|
||||
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes);
|
||||
status.NextUpdateTime = now.AddMinutes(minutesUntilNextCheck);
|
||||
status.NextUpdateTime = ResponseBuilder.CalculateNextServiceCheck(cacheManagementCheckIntervalMinutes);
|
||||
}
|
||||
|
||||
return status;
|
||||
|
||||
+22
-17
@@ -14,20 +14,17 @@ namespace BomLocalService.Services;
|
||||
public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
|
||||
{
|
||||
_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
|
||||
var debugEnabledEnv = Environment.GetEnvironmentVariable("DEBUG__ENABLED");
|
||||
var debugEnabledConfig = 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;
|
||||
// Get debug enabled from configuration (default from appsettings.json, can be overridden via DEBUG__ENABLED environment variable)
|
||||
// Note: bool defaults to false if not found, which is acceptable for Debug:Enabled
|
||||
_enabled = configuration.GetValue<bool>("Debug:Enabled", false);
|
||||
|
||||
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||
_debugDirectory = Path.Combine(cacheDirectory, "debug");
|
||||
@@ -90,18 +87,22 @@ namespace BomLocalService.Services;
|
||||
await File.WriteAllTextAsync(htmlPath, htmlContent, cancellationToken);
|
||||
|
||||
// Save console messages if provided
|
||||
// Create a snapshot to avoid collection modification during enumeration
|
||||
if (consoleMessages != null && consoleMessages.Count > 0)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// Save network requests summary if provided
|
||||
// Create a snapshot to avoid collection modification during enumeration
|
||||
if (networkRequests != null && networkRequests.Count > 0)
|
||||
{
|
||||
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})"
|
||||
));
|
||||
await File.WriteAllTextAsync(networkPath, networkText, cancellationToken);
|
||||
@@ -148,18 +149,22 @@ namespace BomLocalService.Services;
|
||||
}
|
||||
|
||||
// Save console messages if provided
|
||||
// Create a snapshot to avoid collection modification during enumeration
|
||||
if (consoleMessages != null && consoleMessages.Count > 0)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// Save network requests summary if provided
|
||||
// Create a snapshot to avoid collection modification during enumeration
|
||||
if (networkRequests != null && networkRequests.Count > 0)
|
||||
{
|
||||
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})"
|
||||
));
|
||||
await File.WriteAllTextAsync(networkPath, networkText, cancellationToken);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Main service interface for BOM radar screenshot operations.
|
||||
/// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations.
|
||||
/// </summary>
|
||||
public interface IBomRadarService
|
||||
public interface IBomRadarService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets cached radar data for a location.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for managing Playwright browser instances and automation.
|
||||
/// Handles browser lifecycle, context creation, and anti-detection measures for web scraping.
|
||||
/// </summary>
|
||||
public interface IBrowserService : IDisposable
|
||||
public interface IBrowserService : ISingletonService, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new browser context with proper configuration for BOM website scraping.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for managing cached radar screenshot files and metadata.
|
||||
/// Handles file system operations for storing and retrieving cached BOM radar screenshots.
|
||||
/// </summary>
|
||||
public interface ICacheService
|
||||
public interface ICacheService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <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>
|
||||
/// <param name="cacheFolderPath">Full path to the cache folder</param>
|
||||
/// <param name="dataType">The type of cached data</param>
|
||||
@@ -195,5 +196,50 @@ public interface ICacheService
|
||||
int cacheExpirationMinutes,
|
||||
int cacheManagementCheckIntervalMinutes,
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for debug file generation during web scraping operations.
|
||||
/// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting.
|
||||
/// </summary>
|
||||
public interface IDebugService
|
||||
public interface IDebugService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether debug mode is enabled.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// Service interface for scraping radar screenshots from the BOM website.
|
||||
/// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots.
|
||||
/// </summary>
|
||||
public interface IScrapingService
|
||||
public interface IScrapingService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Scrapes a radar screenshot from the BOM website for a given location.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces.Registration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface ITimeParsingService
|
||||
public interface ITimeParsingService : ISingletonService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,609 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.Playwright;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Steps.Capture;
|
||||
|
||||
public class CaptureFramesStep : BaseScrapingStep
|
||||
{
|
||||
private readonly ICacheService _cacheService;
|
||||
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)
|
||||
: base(logger, selectorService, debugService, configuration)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
|
||||
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 ExtractTimestampFromDisplayAsync(context.Page, context);
|
||||
if (frameTimestamp == null)
|
||||
{
|
||||
// Retry once after a short wait in case label was updating
|
||||
await context.Page.WaitForTimeoutAsync(200);
|
||||
frameTimestamp = await ExtractTimestampFromDisplayAsync(context.Page, context);
|
||||
}
|
||||
|
||||
// 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 WaitForDisplayLabelToChangeAsync(context.Page, previousTimestamp.Value, context);
|
||||
frameTimestamp = await ExtractTimestampFromDisplayAsync(context.Page, context);
|
||||
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 ExtractTimestampFromDisplayAsync(context.Page, context);
|
||||
|
||||
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
|
||||
if (currentTimestamp.HasValue)
|
||||
{
|
||||
await WaitForDisplayLabelToChangeAsync(context.Page, currentTimestamp.Value);
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the UTC timestamp from the frame display label
|
||||
/// </summary>
|
||||
private async Task<DateTime?> ExtractTimestampFromDisplayAsync(IPage page, ScrapingContext? context = null)
|
||||
{
|
||||
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.LogInformation("Extracting timestamp from display label: '{Label}'", trimmedLabel);
|
||||
|
||||
// Parse timestamp format (current BOM website format): "Wednesday 17 Dec, 11:05 pm" or "17 Dec, 11:05 pm"
|
||||
Logger.LogInformation("Trying timestamp pattern: '{Pattern}'", TextPatterns.TimestampPattern);
|
||||
var timestampMatch = Regex.Match(trimmedLabel, TextPatterns.TimestampPattern, RegexOptions.IgnoreCase);
|
||||
if (timestampMatch.Success)
|
||||
{
|
||||
var timestampStr = timestampMatch.Groups[0].Value;
|
||||
|
||||
// Check for timezone abbreviation in the full label (not just the matched timestamp)
|
||||
// BOM website may display timezone elsewhere in the label text
|
||||
string? detectedTimezone = null;
|
||||
if (trimmedLabel.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
detectedTimezone = "AEDT";
|
||||
}
|
||||
else if (trimmedLabel.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
detectedTimezone = "AEST";
|
||||
}
|
||||
|
||||
// Fallback: If timezone not found in frame label, try to extract it from metadata
|
||||
// The metadata has the timezone (e.g., "9:40 pm AEST"), so we can use that for frames too
|
||||
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";
|
||||
Logger.LogDebug("Detected timezone AEDT from metadata text");
|
||||
}
|
||||
else if (metadataText.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
detectedTimezone = "AEST";
|
||||
Logger.LogDebug("Detected timezone AEST from metadata text");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Failed to extract timezone from metadata, using configured timezone");
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogInformation("Matched timestamp pattern: '{Timestamp}' from label: '{Label}' (detected timezone: {Tz})",
|
||||
timestampStr, trimmedLabel, detectedTimezone ?? "none");
|
||||
|
||||
if (TryParseTimestamp(timestampStr, detectedTimezone, out var frameTimestampUtc))
|
||||
{
|
||||
Logger.LogInformation("Successfully parsed frame timestamp: {Timestamp} UTC", frameTimestampUtc);
|
||||
return frameTimestampUtc;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Failed to parse timestamp string: '{Timestamp}'", timestampStr);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Display label did not match timestamp pattern. Label: '{Label}', Pattern: '{Pattern}'", trimmedLabel, TextPatterns.TimestampPattern);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Failed to extract timestamp from display label");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc)
|
||||
{
|
||||
timestampUtc = DateTime.MinValue;
|
||||
|
||||
try
|
||||
{
|
||||
// Determine timezone based on detected abbreviation or configured default
|
||||
TimeZoneInfo timeZoneInfo;
|
||||
|
||||
if (!string.IsNullOrEmpty(timezoneAbbreviation))
|
||||
{
|
||||
// Map timezone abbreviations to actual timezones (same logic as TimeParsingService)
|
||||
// - "AEST" = UTC+10 (Australian Eastern Standard Time) - Brisbane year-round, Sydney/Melbourne in winter
|
||||
// - "AEDT" = UTC+11 (Australian Eastern Daylight Time) - Sydney/Melbourne in summer (Oct-Apr), never Brisbane
|
||||
if (timezoneAbbreviation.Contains("AEST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
|
||||
Logger.LogDebug("Using Brisbane timezone (AEST, UTC+10) for frame timestamp");
|
||||
}
|
||||
else if (timezoneAbbreviation.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
|
||||
Logger.LogDebug("Using Sydney timezone (AEDT, UTC+11) for frame timestamp");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to configured timezone if abbreviation is unrecognized
|
||||
var timezone = Configuration.GetValue<string>("Timezone");
|
||||
if (string.IsNullOrEmpty(timezone))
|
||||
{
|
||||
Logger.LogWarning("Timezone not configured, cannot parse timestamp correctly");
|
||||
return false;
|
||||
}
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
|
||||
Logger.LogDebug("Using configured timezone '{Timezone}' for frame timestamp", timezone);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No timezone detected - use configured default
|
||||
var timezone = Configuration.GetValue<string>("Timezone");
|
||||
if (string.IsNullOrEmpty(timezone))
|
||||
{
|
||||
Logger.LogWarning("Timezone not configured, cannot parse timestamp correctly");
|
||||
return false;
|
||||
}
|
||||
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
|
||||
Logger.LogDebug("No timezone abbreviation detected, using configured timezone '{Timezone}' for frame timestamp", timezone);
|
||||
}
|
||||
|
||||
// Try common Australian date formats
|
||||
// Format: "Wednesday 17 Dec, 11:05 pm" or "17 Dec, 11:05 pm"
|
||||
var formats = new[]
|
||||
{
|
||||
"dddd d MMM, h:mm tt", // Wednesday 17 Dec, 11:05 pm
|
||||
"d MMM, h:mm tt", // 17 Dec, 11:05 pm
|
||||
"dddd d MMM, hh:mm tt", // Wednesday 17 Dec, 11:05 pm (with leading zero)
|
||||
"d MMM, hh:mm tt", // 17 Dec, 11:05 pm (with leading zero)
|
||||
"dddd dd MMM, h:mm tt", // Wednesday 17 Dec, 11:05 pm (with leading zero day)
|
||||
"dd MMM, h:mm tt" // 17 Dec, 11:05 pm (with leading zero day)
|
||||
};
|
||||
|
||||
var culture = new System.Globalization.CultureInfo("en-AU");
|
||||
DateTime localTime = default;
|
||||
bool 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 year is not specified, assume current year
|
||||
if (localTime.Year == 1)
|
||||
{
|
||||
localTime = new DateTime(DateTime.UtcNow.Year, localTime.Month, localTime.Day,
|
||||
localTime.Hour, localTime.Minute, localTime.Second);
|
||||
}
|
||||
|
||||
// The parsed timestamp includes both date and time (e.g., "23 Dec, 11:45 pm")
|
||||
// localTime already contains the complete date and time from the parsed string
|
||||
// We treat it as being in the target timezone (DateTimeKind.Unspecified), then convert to UTC
|
||||
timestampUtc = TimeZoneInfo.ConvertTimeToUtc(localTime, timeZoneInfo);
|
||||
|
||||
Logger.LogDebug("Parsed frame timestamp '{TimestampStr}' with timezone '{TzAbbrev}' as {LocalTime} local ({UtcTime} UTC)",
|
||||
timestampStr, timezoneAbbreviation ?? "default", localTime, timestampUtc);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to parse timestamp: {Timestamp}", timestampStr);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForDisplayLabelToChangeAsync(IPage page, DateTime currentTimestamp, ScrapingContext? context = null, int maxWaitMs = 5000)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
|
||||
{
|
||||
var newTimestamp = await ExtractTimestampFromDisplayAsync(page, context);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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,66 @@
|
||||
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 });
|
||||
|
||||
var buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
|
||||
if (buttonLabel?.Trim().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);
|
||||
|
||||
buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
|
||||
if (buttonLabel?.Trim().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,108 @@
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Steps.Map;
|
||||
|
||||
public class ResetToFirstFrameStep : BaseScrapingStep
|
||||
{
|
||||
public override string Name => "ResetToFirstFrame";
|
||||
public override string[] Prerequisites => new[] { "PauseRadar" };
|
||||
|
||||
public ResetToFirstFrameStep(
|
||||
ILogger<ResetToFirstFrameStep> logger,
|
||||
ISelectorService selectorService,
|
||||
IDebugService debugService,
|
||||
IConfiguration configuration)
|
||||
: base(logger, selectorService, debugService, configuration)
|
||||
{
|
||||
}
|
||||
|
||||
public override bool CanExecute(ScrapingContext context)
|
||||
{
|
||||
return context.CurrentState >= PageState.SlideshowPaused;
|
||||
}
|
||||
|
||||
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Resetting to first frame (frame 0)", Name);
|
||||
|
||||
try
|
||||
{
|
||||
// Try JavaScript click first to avoid thumb interception
|
||||
var clicked = await context.Page.EvaluateAsync<bool>(@"() => {
|
||||
const segment = document.querySelector('[data-testid=""bom-scrub-segment""][data-id=""0""]');
|
||||
if (segment) {
|
||||
segment.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}");
|
||||
|
||||
if (clicked)
|
||||
{
|
||||
await context.Page.WaitForTimeoutAsync(500);
|
||||
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via JavaScript", Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to locator click with force
|
||||
var firstFrameSegment = context.Page.Locator("[data-testid='bom-scrub-segment'][data-id='0']").First;
|
||||
await firstFrameSegment.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
|
||||
await firstFrameSegment.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
await context.Page.WaitForTimeoutAsync(500);
|
||||
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via force click", Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Step {Step}: Failed to click first frame segment, continuing anyway", Name);
|
||||
}
|
||||
|
||||
await SaveDebugAsync(context, 9, "frame_0_selected", cancellationToken);
|
||||
|
||||
// Verify scrubber is at position 0
|
||||
Logger.LogInformation("Step {Step}: Verifying scrubber is at position 0", Name);
|
||||
try
|
||||
{
|
||||
var activeSegment = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
|
||||
|
||||
if (activeSegment)
|
||||
{
|
||||
Logger.LogInformation("Step {Step}: Scrubber confirmed at position 0", Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("Step {Step}: Could not confirm scrubber position, but continuing", Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Step {Step}: Scrubber position verification failed, continuing anyway", Name);
|
||||
}
|
||||
|
||||
await SaveDebugAsync(context, 10, "scrubber_at_position_0", cancellationToken);
|
||||
|
||||
// Wait for frame 0 tiles to fully render
|
||||
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;
|
||||
await context.Page.WaitForTimeoutAsync(tileRenderWaitMs);
|
||||
|
||||
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,68 @@
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Steps.Metadata;
|
||||
|
||||
public class ExtractMetadataStep : BaseScrapingStep
|
||||
{
|
||||
private readonly ITimeParsingService _timeParsingService;
|
||||
|
||||
public override string Name => "ExtractMetadata";
|
||||
public override string[] Prerequisites => new[] { "ResetToFirstFrame" };
|
||||
|
||||
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;
|
||||
|
||||
// Extract frame info
|
||||
try
|
||||
{
|
||||
var frameInfo = await context.Page.EvaluateAsync<object[]>(JavaScriptTemplates.ExtractFrameInfo);
|
||||
var result = new List<(int index, int minutesAgo)>();
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var minutesAgo = 40 - (i * 5);
|
||||
result.Add((i, minutesAgo));
|
||||
}
|
||||
context.FrameInfo = result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Step {Step}: Failed to extract frame info, using defaults", Name);
|
||||
context.FrameInfo = Enumerable.Range(0, 7)
|
||||
.Select(i => (i, 40 - (i * 5)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RadarResponse>)
|
||||
/// </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<RadarResponse>)
|
||||
/// </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",
|
||||
"ResetToFirstFrame",
|
||||
"ExtractMetadata",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Utilities;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
@@ -8,67 +8,16 @@ namespace BomLocalService.Services;
|
||||
public class ScrapingService : IScrapingService
|
||||
{
|
||||
private readonly ILogger<ScrapingService> _logger;
|
||||
private readonly IBrowserService _browserService;
|
||||
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']"
|
||||
};
|
||||
|
||||
private readonly IWorkflowFactory _workflowFactory;
|
||||
|
||||
public ScrapingService(
|
||||
ILogger<ScrapingService> logger,
|
||||
IBrowserService browserService,
|
||||
ITimeParsingService timeParsingService,
|
||||
ICacheService cacheService,
|
||||
IDebugService debugService,
|
||||
IConfiguration configuration)
|
||||
IWorkflowFactory workflowFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_browserService = browserService;
|
||||
_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);
|
||||
_workflowFactory = workflowFactory;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes the BOM website to get a radar screenshot for a location
|
||||
/// </summary>
|
||||
@@ -82,812 +31,22 @@ public class ScrapingService : IScrapingService
|
||||
List<(string method, string url, int? status, string resourceType, DateTime timestamp)> networkRequests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
var context = new ScrapingContext
|
||||
{
|
||||
// Step 1: Navigate to BOM homepage and wait for search button
|
||||
_logger.LogInformation("Navigating to BOM homepage");
|
||||
await page.GotoAsync("https://www.bom.gov.au/", new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 30000
|
||||
});
|
||||
|
||||
// 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
|
||||
Page = page,
|
||||
Suburb = suburb,
|
||||
State = state,
|
||||
CacheFolderPath = cacheFolderPath,
|
||||
DebugFolder = debugFolder,
|
||||
ConsoleMessages = consoleMessages,
|
||||
NetworkRequests = networkRequests
|
||||
};
|
||||
}
|
||||
|
||||
/// <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
|
||||
var viewportSize = page.ViewportSize;
|
||||
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;
|
||||
}
|
||||
// Get the appropriate workflow (currently only RadarScraping is implemented)
|
||||
var workflow = _workflowFactory.GetWorkflow<RadarResponse>("RadarScraping");
|
||||
|
||||
// Final validation - ensure dimensions are positive
|
||||
if (cropArea.Width <= 0 || cropArea.Height <= 0)
|
||||
{
|
||||
_logger.LogError("Invalid crop dimensions: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height);
|
||||
cropArea = containerClip;
|
||||
}
|
||||
_logger.LogInformation("Executing {Workflow} workflow for {Suburb}, {State}", workflow.Name, suburb, state);
|
||||
|
||||
// Wait for fonts to be loaded to prevent text rendering artifacts
|
||||
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);
|
||||
return await workflow.ExecuteAsync(context, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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}");
|
||||
}
|
||||
|
||||
// Returns first selector as locator (for cases where we know it exists)
|
||||
return page.Locator(config.Selectors[0]).First;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ public class TimeParsingService : ITimeParsingService
|
||||
public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration)
|
||||
{
|
||||
_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
|
||||
{
|
||||
@@ -23,8 +29,8 @@ public class TimeParsingService : ITimeParsingService
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
_logger.LogWarning("Timezone {Timezone} not found, defaulting to Australia/Brisbane", timezone);
|
||||
_timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
|
||||
_logger.LogError("Timezone {Timezone} not found. Please use a valid IANA timezone identifier.", timezone);
|
||||
throw new InvalidOperationException($"Invalid timezone '{timezone}'. Please use a valid IANA timezone identifier (e.g., Australia/Sydney, Australia/Brisbane).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,33 +67,23 @@ public class TimeParsingService : ITimeParsingService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to extract last updated information");
|
||||
return new LastUpdatedInfo
|
||||
{
|
||||
ObservationTime = DateTime.UtcNow,
|
||||
ForecastTime = DateTime.UtcNow
|
||||
};
|
||||
_logger.LogError(ex, "Failed to extract last updated information");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public LastUpdatedInfo ParseLastUpdatedText(string text)
|
||||
{
|
||||
var info = new LastUpdatedInfo();
|
||||
_logger.LogInformation("Parsing last updated text: {Text}", text);
|
||||
|
||||
// Try to parse "Observations: 11 minutes ago, 8:20 pm AEST at Gympie weather station, 30 km from Pomona, QLD"
|
||||
// And "Forecast: 41 minutes ago, 7:50 pm AEST"
|
||||
// Prefer parsing the actual time string over "minutes ago" for accuracy
|
||||
|
||||
// Parse observation time - improved regex to better capture time and timezone
|
||||
// Stop timezone capture before "at" (e.g., "AEST at Gympie" or "AESTat Gympie" should capture just "AEST")
|
||||
// Pattern: "Observations: X minutes ago, 9:40 pm AESTat Gympie..." (note: sometimes no space between AEST and "at")
|
||||
// Capture timezone as 3-4 uppercase letters (AEST, AEDT, etc.) optionally followed by "at"
|
||||
// Parse observation time from time string (e.g., "9:40 pm AEST")
|
||||
// Ignore "minutes ago" - client will calculate this dynamically from UTC timestamp
|
||||
var observationMatch = Regex.Match(text, @"Observations:\s*(?:\d+\s*minutes?\s*ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)", RegexOptions.IgnoreCase);
|
||||
_logger.LogDebug("Observation regex match result: Success={Success}, Groups.Count={Count}", observationMatch.Success, observationMatch.Groups.Count);
|
||||
if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
|
||||
{
|
||||
var timeStr = observationMatch.Groups[1].Value.Trim();
|
||||
@@ -99,30 +95,27 @@ public class TimeParsingService : ITimeParsingService
|
||||
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Extracted observation time string: '{TimeStr}', timezone: '{TzStr}'", timeStr, timezoneStr ?? "null");
|
||||
|
||||
if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
|
||||
{
|
||||
info.ObservationTime = observationTime;
|
||||
_logger.LogInformation("Successfully parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
_logger.LogInformation("Parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
observationTime, timeStr, timezoneStr ?? "default timezone");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to "minutes ago" if time parsing fails
|
||||
_logger.LogWarning("Failed to parse observation time from '{TimeStr}', falling back to 'minutes ago'", timeStr);
|
||||
var minutesAgoMatch = Regex.Match(text, @"Observations:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
|
||||
if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
|
||||
{
|
||||
info.ObservationTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
|
||||
_logger.LogInformation("Used 'minutes ago' fallback: {MinutesAgo} minutes ago = {Time} UTC",
|
||||
minutesAgo, info.ObservationTime);
|
||||
}
|
||||
_logger.LogError("Failed to parse observation time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
|
||||
throw new InvalidOperationException($"Failed to parse observation time from text: {text}");
|
||||
}
|
||||
}
|
||||
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
|
||||
var forecastMatch = Regex.Match(text, @"Forecast:\s*(?:\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)", RegexOptions.IgnoreCase);
|
||||
// Parse forecast time from time string (e.g., "7:50 pm AEST")
|
||||
// Ignore "minutes ago" - client will calculate this dynamically from UTC timestamp
|
||||
var forecastMatch = Regex.Match(text, @"Forecast:\s*(?: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)
|
||||
{
|
||||
var timeStr = forecastMatch.Groups[1].Value.Trim();
|
||||
@@ -131,17 +124,20 @@ public class TimeParsingService : ITimeParsingService
|
||||
if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
|
||||
{
|
||||
info.ForecastTime = forecastTime;
|
||||
_logger.LogInformation("Parsed forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
|
||||
forecastTime, timeStr, timezoneStr ?? "default timezone");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to "minutes ago" if time parsing fails
|
||||
var minutesAgoMatch = Regex.Match(text, @"Forecast:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
|
||||
if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
|
||||
{
|
||||
info.ForecastTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
|
||||
}
|
||||
_logger.LogError("Failed to parse forecast time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
|
||||
throw new InvalidOperationException($"Failed to parse forecast time from text: {text}");
|
||||
}
|
||||
}
|
||||
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
|
||||
var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase);
|
||||
@@ -194,20 +190,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;
|
||||
if (!string.IsNullOrEmpty(timezoneStr))
|
||||
{
|
||||
// AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney
|
||||
// Both Brisbane and Sydney use AEST/AEDT
|
||||
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) ||
|
||||
timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
|
||||
// Map timezone abbreviations to actual timezones:
|
||||
// - "AEST" = UTC+10 (Australian Eastern Standard Time)
|
||||
// Used by Brisbane year-round, and by Sydney/Melbourne during winter
|
||||
// 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
|
||||
{
|
||||
// Try to find the timezone, fallback to configured
|
||||
// Try to find the timezone by name, fallback to configured browser timezone
|
||||
try
|
||||
{
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
|
||||
@@ -223,18 +231,17 @@ public class TimeParsingService : ITimeParsingService
|
||||
var nowInTz = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
|
||||
var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay);
|
||||
|
||||
// If the parsed time is in the future (e.g., it's 9pm but we parsed 8pm and it's actually yesterday),
|
||||
// or if it's more than 12 hours in the past, assume it's from yesterday
|
||||
// Only adjust to yesterday if the time is clearly in the future
|
||||
// This ensures we get the correct date for the observation/forecast time
|
||||
if (dateTimeInTz > nowInTz)
|
||||
{
|
||||
// Time is in the future, so it must be from yesterday
|
||||
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)
|
||||
{
|
||||
// More than 12 hours ago, likely from yesterday
|
||||
dateTimeInTz = dateTimeInTz.AddDays(-1);
|
||||
}
|
||||
// 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
|
||||
|
||||
// Convert to UTC
|
||||
// Note: Brisbane (Australia/Brisbane) is always UTC+10 (AEST), no daylight saving
|
||||
|
||||
@@ -14,8 +14,12 @@ public static class CacheHelper
|
||||
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
|
||||
{
|
||||
var dataTypeName = dataType.ToString();
|
||||
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7);
|
||||
return frameCount;
|
||||
var frameCountConfig = configuration.GetValue<int?>($"CachedDataTypes:{dataTypeName}: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>
|
||||
@@ -61,5 +65,40 @@ public static class CacheHelper
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ namespace BomLocalService.Utilities;
|
||||
public static class FilePathHelper
|
||||
{
|
||||
/// <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>
|
||||
public static string GetCacheDirectory(IConfiguration configuration)
|
||||
{
|
||||
return configuration.GetValue<string>("CacheDirectory")
|
||||
?? Path.Combine(AppContext.BaseDirectory, "cache");
|
||||
return configuration.GetValue<string>("CacheDirectory")
|
||||
?? throw new InvalidOperationException("CacheDirectory configuration is required. Set it in appsettings.json or via CACHEDIRECTORY environment variable.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -22,7 +22,8 @@ public static class LocationHelper
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// </summary>
|
||||
public static (string suburb, string state)? ParseLocationFromFilename(string fileName)
|
||||
@@ -30,12 +31,21 @@ public static class LocationHelper
|
||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||
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>
|
||||
|
||||
@@ -4,20 +4,48 @@ namespace BomLocalService.Utilities;
|
||||
|
||||
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>
|
||||
/// Creates a RadarResponse from a cache folder path, frames, and metadata
|
||||
/// </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="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(
|
||||
string cacheFolderPath,
|
||||
List<RadarFrame> frames,
|
||||
int cacheManagementCheckIntervalMinutes,
|
||||
LastUpdatedInfo? metadata = null,
|
||||
string? suburb = null,
|
||||
string? state = null,
|
||||
bool? cacheIsValid = null,
|
||||
DateTime? cacheExpiresAt = null,
|
||||
bool isUpdating = false,
|
||||
int cacheManagementCheckIntervalMinutes = 5)
|
||||
int? estimatedUpdateDurationSeconds = null)
|
||||
{
|
||||
var folderInfo = new DirectoryInfo(cacheFolderPath);
|
||||
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:
|
||||
// - 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)
|
||||
@@ -44,13 +75,13 @@ public static class ResponseBuilder
|
||||
|
||||
// Calculate next background service check time (rounds up to next check interval)
|
||||
var now = DateTime.UtcNow;
|
||||
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes);
|
||||
var nextServiceCheck = now.AddMinutes(minutesUntilNextCheck);
|
||||
var nextServiceCheck = CalculateNextServiceCheck(cacheManagementCheckIntervalMinutes);
|
||||
|
||||
if (isUpdating)
|
||||
{
|
||||
// Update in progress - estimate completion in ~2 minutes
|
||||
nextUpdateTime = now.AddMinutes(2);
|
||||
// Update in progress - estimate completion based on configured/calculated duration
|
||||
var durationSeconds = estimatedUpdateDurationSeconds ?? 120; // Default to 2 minutes if not provided
|
||||
nextUpdateTime = now.AddSeconds(durationSeconds);
|
||||
}
|
||||
else if (cacheIsValid == true && cacheExpiresAt.HasValue)
|
||||
{
|
||||
|
||||
+13
-2016
File diff suppressed because it is too large
Load Diff
+207
-3
@@ -6,21 +6,26 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Cors": {
|
||||
"AllowedOrigins": "*",
|
||||
"AllowedMethods": "GET,POST,OPTIONS",
|
||||
"AllowedHeaders": "*",
|
||||
"AllowCredentials": false
|
||||
},
|
||||
"CacheDirectory": "./cache",
|
||||
"CacheRetentionHours": 24,
|
||||
"CacheExpirationMinutes": 12.5,
|
||||
"CacheManagement": {
|
||||
"CheckIntervalMinutes": 5,
|
||||
"InitialDelaySeconds": 10,
|
||||
"UpdateStaggerSeconds": 2,
|
||||
"LocationStaggerSeconds": 1
|
||||
},
|
||||
"CacheCleanup": {
|
||||
"IntervalHours": 1
|
||||
},
|
||||
"Screenshot": {
|
||||
"DynamicContentWaitMs": 2000,
|
||||
"TileRenderWaitMs": 5000,
|
||||
"DynamicContentWaitMs": 1500,
|
||||
"TileRenderWaitMs": 3000,
|
||||
"Crop": {
|
||||
"X": 250,
|
||||
"Y": 0,
|
||||
@@ -37,5 +42,204 @@
|
||||
"Radar": {
|
||||
"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[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"
|
||||
],
|
||||
"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-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-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\"]')); 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]{3}),?\\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]{3},?\\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 },
|
||||
"ResetToFirstFrame": { "Enabled": true },
|
||||
"ExtractMetadata": { "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
@@ -17,6 +17,14 @@ services:
|
||||
# Virtual display for non-headless browser
|
||||
- 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)
|
||||
# Use double underscore (__) for nested config keys
|
||||
- CACHEDIRECTORY=${CACHEDIRECTORY:-/app/cache}
|
||||
@@ -24,7 +32,6 @@ services:
|
||||
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
|
||||
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
|
||||
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
|
||||
- CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
|
||||
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
|
||||
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
|
||||
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@import url('./reset.css');
|
||||
@import url('./layout.css');
|
||||
@import url('./components.css');
|
||||
@import url('./frame-slider.css');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user