mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-04 03:28:11 +10:00
Refactor scraping system to workflow-based architecture
Major architectural changes: - Replace monolithic ScrapingService with workflow-based system - Extract scraping logic into discrete, testable steps - Add configuration-driven selectors, JavaScript templates, and text patterns - Implement step registry and workflow factory for extensibility - Add PageState tracking and step dependencies - Make IWorkflow generic to support different response types New components: - Scraping steps organized by category (Navigation, Search, Map, Metadata, Capture) - BaseScrapingStep base class with common dependencies - ScrapingContext for shared state between steps - RadarScrapingWorkflow orchestrating step execution - SelectorService for configurable element selection - Configuration models (SelectorConfig, TextPatternsConfig, JavaScriptTemplatesConfig) Performance and monitoring: - Add step-level timing metrics with historical tracking - Log step durations and compare to averages - Warn on slow steps/workflows (50%+ slower than average) - Integrate metrics with CacheService Configuration improvements: - Externalize all selectors to appsettings.json - Externalize JavaScript templates to appsettings.json - Externalize text patterns to appsettings.json - Support Docker configuration via mounted appsettings.json - Remove hardcoded waits, use configurable timeouts Bug fixes: - Fix DebugService collection modification exception - Fix ResetToFirstFrameStep click interception with JavaScript fallback - Remove minutes ago fallback, use timestamp parsing only - Update timestamp parsing for new BOM website format Documentation: - Update README with new architecture details - Add configuration section with Docker guidance - Add development guidelines for extending scraping system - Add performance monitoring documentation
This commit is contained in:
@@ -18,6 +18,7 @@ public class CacheService : ICacheService
|
||||
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;
|
||||
|
||||
@@ -589,7 +590,7 @@ public class CacheService : ICacheService
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache update completed in {Duration:F1} seconds for {Location}", totalDuration, locationKey);
|
||||
_logger.LogInformation("Cache update completed in {Duration:F1} seconds for {Location}", totalDuration, locationKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,7 +666,7 @@ public class CacheService : ICacheService
|
||||
/// <summary>
|
||||
/// Gets the average total duration of cache updates from recent metrics.
|
||||
/// </summary>
|
||||
private double GetAverageTotalDuration()
|
||||
public double GetAverageTotalDuration()
|
||||
{
|
||||
lock (_metricsLock)
|
||||
{
|
||||
@@ -707,6 +708,59 @@ public class CacheService : ICacheService
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -90,18 +90,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 +152,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);
|
||||
|
||||
@@ -213,5 +213,32 @@ public interface ICacheService
|
||||
/// <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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using BomLocalService.Models;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace BomLocalService.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for finding page elements using configured selectors
|
||||
/// </summary>
|
||||
public interface ISelectorService
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a single scraping step
|
||||
/// </summary>
|
||||
public interface IScrapingStep
|
||||
{
|
||||
/// <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,12 @@
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for managing scraping steps
|
||||
/// </summary>
|
||||
public interface IScrapingStepRegistry
|
||||
{
|
||||
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,10 @@
|
||||
namespace BomLocalService.Services.Scraping;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating workflows
|
||||
/// </summary>
|
||||
public interface IWorkflowFactory
|
||||
{
|
||||
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,33 @@
|
||||
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)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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,489 @@
|
||||
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;
|
||||
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
||||
|
||||
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")
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
int? previousMinutesAgo = 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 with a retry in case the label is still updating
|
||||
var minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
if (minutesAgo == null)
|
||||
{
|
||||
// Retry once after a short wait in case label was updating
|
||||
await context.Page.WaitForTimeoutAsync(200);
|
||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
}
|
||||
if (minutesAgo == null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
|
||||
{
|
||||
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||
minutesAgo = defaultMinutesAgo;
|
||||
Logger.LogWarning("Step {Step}: Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", Name, frameIndex, minutesAgo);
|
||||
}
|
||||
|
||||
if (frameIndex > 0 && previousMinutesAgo.HasValue && minutesAgo == previousMinutesAgo.Value)
|
||||
{
|
||||
Logger.LogWarning("Step {Step}: Frame {FrameIndex} has same minutesAgo ({MinutesAgo}) as previous frame. Waiting for display to update...", Name, frameIndex, minutesAgo);
|
||||
await WaitForDisplayLabelToChangeAsync(context.Page, previousMinutesAgo.Value);
|
||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value)
|
||||
{
|
||||
if (context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
|
||||
{
|
||||
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
|
||||
minutesAgo = defaultMinutesAgo;
|
||||
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", Name, frameIndex, minutesAgo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
MinutesAgo = minutesAgo ?? 0
|
||||
});
|
||||
|
||||
previousMinutesAgo = minutesAgo;
|
||||
|
||||
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
|
||||
Name, frameIndex, framePath, minutesAgo ?? 0);
|
||||
|
||||
_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 currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
|
||||
|
||||
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
|
||||
if (currentMinutesAgo.HasValue)
|
||||
{
|
||||
await WaitForDisplayLabelToChangeAsync(context.Page, currentMinutesAgo.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}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int?> ExtractMinutesAgoFromDisplayAsync(IPage page)
|
||||
{
|
||||
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 minutes 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;
|
||||
Logger.LogInformation("Matched timestamp pattern: '{Timestamp}' from label: '{Label}'", timestampStr, trimmedLabel);
|
||||
if (TryParseTimestamp(timestampStr, out var timestamp))
|
||||
{
|
||||
var minutesAgo = (int)(DateTime.UtcNow - timestamp).TotalMinutes;
|
||||
Logger.LogInformation("Parsed timestamp: {Timestamp} UTC, calculated minutes ago: {Minutes}", timestamp, minutesAgo);
|
||||
if (minutesAgo >= 0 && minutesAgo <= 120) // Reasonable range: 0-2 hours
|
||||
{
|
||||
Logger.LogInformation("Successfully calculated minutes ago from timestamp: {Minutes}", minutesAgo);
|
||||
return minutesAgo;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Calculated minutes ago ({Minutes}) outside reasonable range (0-120)", minutesAgo);
|
||||
}
|
||||
}
|
||||
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 minutes from display label");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseTimestamp(string timestampStr, out DateTime timestamp)
|
||||
{
|
||||
timestamp = DateTime.MinValue;
|
||||
|
||||
try
|
||||
{
|
||||
// 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");
|
||||
|
||||
foreach (var format in formats)
|
||||
{
|
||||
if (DateTime.TryParseExact(timestampStr, format, culture,
|
||||
System.Globalization.DateTimeStyles.AssumeLocal, out timestamp))
|
||||
{
|
||||
// If year is not specified, assume current year
|
||||
if (timestamp.Year == 1)
|
||||
{
|
||||
timestamp = new DateTime(DateTime.Now.Year, timestamp.Month, timestamp.Day,
|
||||
timestamp.Hour, timestamp.Minute, timestamp.Second);
|
||||
}
|
||||
|
||||
// If the parsed time is in the future (likely same day next year), adjust
|
||||
if (timestamp > DateTime.Now && timestamp < DateTime.Now.AddDays(1))
|
||||
{
|
||||
// Already correct
|
||||
}
|
||||
else if (timestamp > DateTime.Now)
|
||||
{
|
||||
// Likely parsed as next year, adjust to this year
|
||||
timestamp = timestamp.AddYears(-1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await page.WaitForTimeoutAsync(200);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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,103 @@
|
||||
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 tileRenderWaitMs = Configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
||||
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,73 @@
|
||||
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)
|
||||
{
|
||||
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
|
||||
}
|
||||
|
||||
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,62 @@
|
||||
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") ?? "https://www.bom.gov.au/";
|
||||
}
|
||||
|
||||
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,192 @@
|
||||
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 dynamicContentWaitMs = Configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000);
|
||||
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,22 @@
|
||||
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" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.RadarScrapingWorkflow>(),
|
||||
"TemperatureMap" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.TemperatureMapWorkflow>(),
|
||||
_ => throw new ArgumentException($"Unknown workflow: {name}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Services.Scraping;
|
||||
using BomLocalService.Utilities;
|
||||
|
||||
namespace BomLocalService.Services.Scraping.Workflows;
|
||||
|
||||
public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
|
||||
{
|
||||
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;
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
|
||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
}
|
||||
|
||||
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 : IWorkflow<RadarResponse>
|
||||
{
|
||||
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
-1004
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user